diff --git a/src/components/Navbars/components/MobileDrawer/MobileDrawer.js b/src/components/Navbars/components/MobileDrawer/MobileDrawer.js
index 3ad0addd..69d448f4 100644
--- a/src/components/Navbars/components/MobileDrawer/MobileDrawer.js
+++ b/src/components/Navbars/components/MobileDrawer/MobileDrawer.js
@@ -84,19 +84,29 @@ const MobileDrawer = memo(({
{isAuthenticated && user && (
<>
-
-
-
- {getDisplayName()}
- {typeof user.phone === 'string' && user.phone && (
- {user.phone}
- )}
-
+
+
+
+
+ {getDisplayName()}
+ {typeof user.phone === 'string' && user.phone && (
+ {user.phone}
+ )}
+
+
+
diff --git a/src/components/Navbars/components/UserMenu/DesktopUserMenu.js b/src/components/Navbars/components/UserMenu/DesktopUserMenu.js
index f742dc16..340e31c8 100644
--- a/src/components/Navbars/components/UserMenu/DesktopUserMenu.js
+++ b/src/components/Navbars/components/UserMenu/DesktopUserMenu.js
@@ -15,7 +15,7 @@ import {
Button,
useDisclosure
} from '@chakra-ui/react';
-import { Settings, LogOut } from 'lucide-react';
+import { Settings, LogOut, Bell } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription';
@@ -134,6 +134,12 @@ const DesktopUserMenu = memo(({ user, handleLogout }) => {
navigate('/home/settings');
};
+ // 跳转到站内信
+ const handleNavigateToInbox = () => {
+ onClose();
+ navigate('/inbox');
+ };
+
// 退出登录
const handleLogoutClick = () => {
onClose();
@@ -208,6 +214,13 @@ const DesktopUserMenu = memo(({ user, handleLogout }) => {
{/* 列表区:快捷功能 */}
+ }
+ onClick={handleNavigateToInbox}
+ py={3}
+ >
+ 站内信
+
}
onClick={handleNavigateToSettings}
diff --git a/src/components/Navbars/components/UserMenu/TabletUserMenu.js b/src/components/Navbars/components/UserMenu/TabletUserMenu.js
index 1b73ee31..b76a4c2c 100644
--- a/src/components/Navbars/components/UserMenu/TabletUserMenu.js
+++ b/src/components/Navbars/components/UserMenu/TabletUserMenu.js
@@ -14,7 +14,7 @@ import {
Badge,
useColorModeValue
} 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 UserAvatar from './UserAvatar';
import { useSubscription } from '../../../../hooks/useSubscription';
@@ -111,6 +111,9 @@ const TabletUserMenu = memo(({
} onClick={() => navigate('/home/center')}>
个人中心
+ } onClick={() => navigate('/inbox')}>
+ 站内信
+
} onClick={() => navigate('/home/profile')}>
个人资料
diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js
index 27c90fb9..e5ec9d1c 100644
--- a/src/routes/lazy-components.js
+++ b/src/routes/lazy-components.js
@@ -56,6 +56,9 @@ export const lazyComponents = {
// 数据浏览器模块
DataBrowser: React.lazy(() => import('@views/DataBrowser')),
+
+ // 站内信模块
+ Inbox: React.lazy(() => import('@views/Inbox')),
};
/**
@@ -88,4 +91,5 @@ export const {
ForumPostDetail,
StockCommunity,
DataBrowser,
+ Inbox,
} = lazyComponents;
diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js
index d63bf420..0a502182 100644
--- a/src/routes/routeConfig.js
+++ b/src/routes/routeConfig.js
@@ -237,6 +237,18 @@ export const routeConfig = [
description: '超炫酷的 AI 投研聊天助手 - 基于 Hero UI'
}
},
+
+ // ==================== 站内信模块 ====================
+ {
+ path: 'inbox',
+ component: lazyComponents.Inbox,
+ protection: PROTECTION_MODES.REDIRECT, // 需要登录才能查看
+ layout: 'main',
+ meta: {
+ title: '站内信',
+ description: '官方公告和互动消息'
+ }
+ },
];
/**
diff --git a/src/services/inboxService.js b/src/services/inboxService.js
new file mode 100644
index 00000000..42e9c6f1
--- /dev/null
+++ b/src/services/inboxService.js
@@ -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,
+};
diff --git a/src/views/Inbox/index.js b/src/views/Inbox/index.js
new file mode 100644
index 00000000..e5dcf50b
--- /dev/null
+++ b/src/views/Inbox/index.js
@@ -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 (
+
+
+
+ );
+ }
+
+ if (announcements.length === 0) {
+ return (
+
+
+ 暂无公告
+
+ );
+ }
+
+ return (
+
+ {/* 标记全部已读按钮 */}
+ {unreadCount > 0 && (
+
+ }
+ onClick={handleMarkAllRead}
+ >
+ 全部已读
+
+
+ )}
+
+ {/* 公告列表 */}
+ {announcements.map((announcement, index) => (
+
+ handleMarkRead(announcement)}
+ _hover={{ shadow: 'md' }}
+ transition="all 0.2s"
+ >
+
+
+
+
+
+
+
+
+
+
+ 官方公告
+
+ {announcement.title && (
+
+ {announcement.title}
+
+ )}
+ {!announcement.isRead && (
+ 未读
+ )}
+
+
+ {formatRelativeTime(announcement.createdAt)}
+
+
+
+ {announcement.content && (
+
+ {announcement.content}
+
+ )}
+
+
+
+
+
+
+ {index < announcements.length - 1 && }
+
+ ))}
+
+ {/* 加载更多 */}
+ {hasMore && (
+
+
+
+ )}
+
+ );
+};
+
+// 通知列表组件
+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 (
+
+
+
+ );
+ }
+
+ if (notifications.length === 0) {
+ return (
+
+
+ 暂无通知
+
+ 当有人回复或提及你时会收到通知
+
+
+ );
+ }
+
+ return (
+
+ {/* 标记全部已读按钮 */}
+ {unreadCount > 0 && (
+
+ }
+ onClick={handleMarkAllRead}
+ >
+ 全部已读
+
+
+ )}
+
+ {/* 通知列表 */}
+ {notifications.map((notification, index) => {
+ const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system;
+
+ return (
+
+ handleMarkRead(notification)}
+ _hover={{ shadow: 'md' }}
+ transition="all 0.2s"
+ >
+
+
+ {notification.senderAvatar ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {notification.senderName && (
+
+ {notification.senderName}
+
+ )}
+
+ {config.label}
+
+ {!notification.isRead && (
+ 未读
+ )}
+
+
+ {formatRelativeTime(notification.createdAt)}
+
+
+
+ {notification.content && (
+
+ {notification.content}
+
+ )}
+
+
+
+
+
+
+ {index < notifications.length - 1 && }
+
+ );
+ })}
+
+ {/* 加载更多 */}
+ {hasMore && (
+
+
+
+ )}
+
+ );
+};
+
+// 主页面组件
+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 (
+
+
+ {/* 页面标题 */}
+
+
+ 站内信
+
+
+ {/* 标签页 */}
+
+
+
+
+
+
+
+ 官方公告
+ {announcementUnread > 0 && (
+
+ {announcementUnread}
+
+ )}
+
+
+
+
+
+ 互动消息
+ {notificationUnread > 0 && (
+
+ {notificationUnread}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default InboxPage;