更新ios
This commit is contained in:
@@ -84,6 +84,7 @@ const MobileDrawer = memo(({
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
<Box p={3} bg={userBgColor} borderRadius="md">
|
||||
<HStack justify="space-between">
|
||||
<HStack>
|
||||
<Avatar
|
||||
size="sm"
|
||||
@@ -98,6 +99,15 @@ const MobileDrawer = memo(({
|
||||
)}
|
||||
</Box>
|
||||
</HStack>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
colorScheme="purple"
|
||||
onClick={() => handleNavigate('/inbox')}
|
||||
>
|
||||
站内信
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
<Divider />
|
||||
</>
|
||||
|
||||
@@ -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 }) => {
|
||||
<MenuDivider my={0} />
|
||||
|
||||
{/* 列表区:快捷功能 */}
|
||||
<MenuItem
|
||||
icon={<Bell size={16} />}
|
||||
onClick={handleNavigateToInbox}
|
||||
py={3}
|
||||
>
|
||||
站内信
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={<Settings size={16} />}
|
||||
onClick={handleNavigateToSettings}
|
||||
|
||||
@@ -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(({
|
||||
<MenuItem icon={<Home size={16} />} onClick={() => navigate('/home/center')}>
|
||||
个人中心
|
||||
</MenuItem>
|
||||
<MenuItem icon={<Bell size={16} />} onClick={() => navigate('/inbox')}>
|
||||
站内信
|
||||
</MenuItem>
|
||||
<MenuItem icon={<User size={16} />} onClick={() => navigate('/home/profile')}>
|
||||
个人资料
|
||||
</MenuItem>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '官方公告和互动消息'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
160
src/services/inboxService.js
Normal file
160
src/services/inboxService.js
Normal 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
519
src/views/Inbox/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user