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 && ( + + + + )} + + {/* 公告列表 */} + {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 && ( + + + + )} + + {/* 通知列表 */} + {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;