更新ios
This commit is contained in:
@@ -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 />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: '官方公告和互动消息'
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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