更新ios
This commit is contained in:
@@ -55,6 +55,7 @@ import PostDetail from "../src/screens/Social/PostDetail";
|
||||
import CreatePost from "../src/screens/Social/CreatePost";
|
||||
import CreateChannel from "../src/screens/Social/CreateChannel";
|
||||
import MemberList from "../src/screens/Social/MemberList";
|
||||
import SocialNotifications from "../src/screens/Social/Notifications";
|
||||
|
||||
// 新个人中心页面
|
||||
import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile";
|
||||
@@ -452,6 +453,13 @@ function NewProfileStack(props) {
|
||||
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Messages"
|
||||
component={SocialNotifications}
|
||||
options={{
|
||||
cardStyle: { backgroundColor: "#0F172A" },
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -356,6 +356,8 @@ const ProfileScreen = () => {
|
||||
navigation.navigate('WatchlistDrawer');
|
||||
} else if (item.route === 'Subscription') {
|
||||
navigation.navigate('Subscription');
|
||||
} else if (item.route === 'Messages') {
|
||||
navigation.navigate('Messages');
|
||||
} else if (item.route === 'Settings') {
|
||||
navigation.navigate('SettingsDrawer');
|
||||
} else if (item.route === 'About') {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 通知页面
|
||||
* 显示社区通知列表
|
||||
* 显示社区通知和官方公告
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
@@ -28,10 +28,18 @@ import {
|
||||
fetchNotifications,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
fetchAnnouncements,
|
||||
markAnnouncementRead,
|
||||
markAllAnnouncementsRead,
|
||||
} from '../../store/slices/socialSlice';
|
||||
|
||||
// 通知类型配置
|
||||
const NOTIFICATION_CONFIG = {
|
||||
announcement: {
|
||||
icon: 'megaphone',
|
||||
color: '#D4AF37',
|
||||
label: '官方公告',
|
||||
},
|
||||
reply: {
|
||||
icon: 'chatbubble',
|
||||
color: '#7C3AED',
|
||||
@@ -52,15 +60,21 @@ const NOTIFICATION_CONFIG = {
|
||||
color: '#3B82F6',
|
||||
label: '关注了你',
|
||||
},
|
||||
message: {
|
||||
icon: 'chatbubble-ellipses',
|
||||
color: '#06B6D4',
|
||||
label: '发来消息',
|
||||
},
|
||||
system: {
|
||||
icon: 'megaphone',
|
||||
color: '#D4AF37',
|
||||
icon: 'information-circle',
|
||||
color: '#6B7280',
|
||||
label: '系统通知',
|
||||
},
|
||||
};
|
||||
|
||||
// 格式化相对时间
|
||||
const formatRelativeTime = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
@@ -83,73 +97,147 @@ const Notifications = ({ navigation }) => {
|
||||
const dispatch = useDispatch();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const communityState = useSelector((state) => state.social);
|
||||
const notifications = communityState?.notifications || [];
|
||||
const notificationsHasMore = communityState?.notificationsHasMore ?? true;
|
||||
const loading = communityState?.loading || {};
|
||||
const unreadCount = communityState?.unreadCount || 0;
|
||||
const socialState = useSelector((state) => state.social);
|
||||
const notifications = socialState?.notifications || [];
|
||||
const announcements = socialState?.announcements || [];
|
||||
const notificationsHasMore = socialState?.notificationsHasMore ?? true;
|
||||
const announcementsHasMore = socialState?.announcementsHasMore ?? true;
|
||||
const loading = socialState?.loading || {};
|
||||
const notificationUnreadCount = socialState?.unreadCount || 0;
|
||||
const announcementUnreadCount = socialState?.announcementUnreadCount || 0;
|
||||
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [notificationPage, setNotificationPage] = useState(1);
|
||||
const [announcementPage, setAnnouncementPage] = useState(1);
|
||||
|
||||
// 加载通知
|
||||
// 合并通知和公告,按时间排序
|
||||
const mergedItems = useMemo(() => {
|
||||
// 将公告转换为统一格式
|
||||
const formattedAnnouncements = announcements.map(a => ({
|
||||
id: `announcement-${a.id}`,
|
||||
originalId: a.id,
|
||||
type: 'announcement',
|
||||
isAnnouncement: true,
|
||||
title: a.title,
|
||||
content: a.content,
|
||||
targetUrl: a.targetUrl,
|
||||
isRead: a.isRead,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
// 合并并按时间排序
|
||||
const merged = [...notifications, ...formattedAnnouncements];
|
||||
merged.sort((a, b) => {
|
||||
const dateA = new Date(a.createdAt || 0);
|
||||
const dateB = new Date(b.createdAt || 0);
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return merged;
|
||||
}, [notifications, announcements]);
|
||||
|
||||
// 总未读数
|
||||
const totalUnreadCount = notificationUnreadCount + announcementUnreadCount;
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotifications({ page: 1 }));
|
||||
setPage(1);
|
||||
dispatch(fetchAnnouncements({ page: 1 }));
|
||||
setNotificationPage(1);
|
||||
setAnnouncementPage(1);
|
||||
}, [dispatch]);
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await dispatch(fetchNotifications({ page: 1 }));
|
||||
setPage(1);
|
||||
await Promise.all([
|
||||
dispatch(fetchNotifications({ page: 1 })),
|
||||
dispatch(fetchAnnouncements({ page: 1 })),
|
||||
]);
|
||||
setNotificationPage(1);
|
||||
setAnnouncementPage(1);
|
||||
setRefreshing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loading.notifications || !notificationsHasMore) return;
|
||||
const isLoadingAny = loading.notifications || loading.announcements;
|
||||
if (isLoadingAny) return;
|
||||
|
||||
const nextPage = page + 1;
|
||||
dispatch(fetchNotifications({ page: nextPage }));
|
||||
setPage(nextPage);
|
||||
}, [dispatch, page, loading.notifications, notificationsHasMore]);
|
||||
// 优先加载有更多数据的类型
|
||||
if (notificationsHasMore) {
|
||||
const nextPage = notificationPage + 1;
|
||||
dispatch(fetchNotifications({ page: nextPage }));
|
||||
setNotificationPage(nextPage);
|
||||
}
|
||||
if (announcementsHasMore) {
|
||||
const nextPage = announcementPage + 1;
|
||||
dispatch(fetchAnnouncements({ page: nextPage }));
|
||||
setAnnouncementPage(nextPage);
|
||||
}
|
||||
}, [dispatch, notificationPage, announcementPage, loading, notificationsHasMore, announcementsHasMore]);
|
||||
|
||||
// 标记已读
|
||||
const handleMarkRead = useCallback((notification) => {
|
||||
if (!notification.isRead) {
|
||||
dispatch(markNotificationRead(notification.id));
|
||||
}
|
||||
// 根据通知类型跳转
|
||||
if (notification.targetType === 'post' && notification.targetId) {
|
||||
navigation.navigate('PostDetail', {
|
||||
post: { id: notification.targetId },
|
||||
channel: {},
|
||||
});
|
||||
const handleMarkRead = useCallback((item) => {
|
||||
if (item.isAnnouncement) {
|
||||
// 公告
|
||||
if (!item.isRead) {
|
||||
dispatch(markAnnouncementRead(item.originalId));
|
||||
}
|
||||
// 如果有跳转链接,可以跳转
|
||||
if (item.targetUrl) {
|
||||
// TODO: 处理 URL 跳转
|
||||
console.log('跳转到:', item.targetUrl);
|
||||
}
|
||||
} else {
|
||||
// 普通通知
|
||||
if (!item.isRead) {
|
||||
dispatch(markNotificationRead(item.id));
|
||||
}
|
||||
// 根据通知类型跳转
|
||||
if (item.targetType === 'post' && item.targetId) {
|
||||
navigation.navigate('PostDetail', {
|
||||
post: { id: item.targetId },
|
||||
channel: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [dispatch, navigation]);
|
||||
|
||||
// 标记全部已读
|
||||
const handleMarkAllRead = useCallback(() => {
|
||||
dispatch(markAllNotificationsRead());
|
||||
}, [dispatch]);
|
||||
if (notificationUnreadCount > 0) {
|
||||
dispatch(markAllNotificationsRead());
|
||||
}
|
||||
if (announcementUnreadCount > 0) {
|
||||
dispatch(markAllAnnouncementsRead());
|
||||
}
|
||||
}, [dispatch, notificationUnreadCount, announcementUnreadCount]);
|
||||
|
||||
// 检查是否可以返回(从个人中心进入时需要返回按钮)
|
||||
const canGoBack = navigation.canGoBack();
|
||||
|
||||
// 渲染头部
|
||||
const renderHeader = () => (
|
||||
<HStack px={4} py={3} alignItems="center" justifyContent="space-between">
|
||||
<HStack alignItems="center" space={2}>
|
||||
{canGoBack && (
|
||||
<Pressable onPress={() => navigation.goBack()} mr={2}>
|
||||
<Icon as={Ionicons} name="chevron-back" size="md" color="white" />
|
||||
</Pressable>
|
||||
)}
|
||||
<Text fontSize="md" fontWeight="bold" color="white">
|
||||
通知
|
||||
消息中心
|
||||
</Text>
|
||||
{unreadCount > 0 ? (
|
||||
{totalUnreadCount > 0 ? (
|
||||
<Box bg="red.500" rounded="full" px={2} py={0.5}>
|
||||
<Text fontSize="2xs" fontWeight="bold" color="white">
|
||||
{unreadCount > 99 ? '99+' : String(unreadCount)}
|
||||
{totalUnreadCount > 99 ? '99+' : String(totalUnreadCount)}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</HStack>
|
||||
{unreadCount > 0 ? (
|
||||
{totalUnreadCount > 0 ? (
|
||||
<Pressable onPress={handleMarkAllRead}>
|
||||
<Text fontSize="xs" color="primary.400">
|
||||
全部已读
|
||||
@@ -159,14 +247,15 @@ const Notifications = ({ navigation }) => {
|
||||
</HStack>
|
||||
);
|
||||
|
||||
// 渲染通知项
|
||||
const renderNotificationItem = ({ item: notification }) => {
|
||||
if (!notification) return null;
|
||||
// 渲染通知/公告项
|
||||
const renderItem = ({ item }) => {
|
||||
if (!item) return null;
|
||||
|
||||
const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system;
|
||||
const isAnnouncement = item.isAnnouncement;
|
||||
const config = NOTIFICATION_CONFIG[item.type] || NOTIFICATION_CONFIG.system;
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => handleMarkRead(notification)}>
|
||||
<Pressable onPress={() => handleMarkRead(item)}>
|
||||
{({ isPressed }) => (
|
||||
<HStack
|
||||
mx={4}
|
||||
@@ -178,22 +267,24 @@ const Notifications = ({ navigation }) => {
|
||||
bg={
|
||||
isPressed
|
||||
? 'rgba(255, 255, 255, 0.08)'
|
||||
: notification.isRead
|
||||
: item.isRead
|
||||
? 'transparent'
|
||||
: isAnnouncement
|
||||
? 'rgba(212, 175, 55, 0.05)'
|
||||
: 'rgba(124, 58, 237, 0.05)'
|
||||
}
|
||||
borderWidth={notification.isRead ? 0 : 1}
|
||||
borderColor="rgba(124, 58, 237, 0.1)"
|
||||
borderWidth={item.isRead ? 0 : 1}
|
||||
borderColor={isAnnouncement ? 'rgba(212, 175, 55, 0.15)' : 'rgba(124, 58, 237, 0.1)'}
|
||||
>
|
||||
{/* 图标/头像 */}
|
||||
<Box position="relative">
|
||||
{notification.senderAvatar ? (
|
||||
{!isAnnouncement && item.senderAvatar ? (
|
||||
<Avatar
|
||||
size="sm"
|
||||
source={{ uri: notification.senderAvatar }}
|
||||
source={{ uri: item.senderAvatar }}
|
||||
bg="primary.600"
|
||||
>
|
||||
{notification.senderName?.[0]?.toUpperCase() || '?'}
|
||||
{item.senderName?.[0]?.toUpperCase() || '?'}
|
||||
</Avatar>
|
||||
) : (
|
||||
<Box
|
||||
@@ -208,7 +299,7 @@ const Notifications = ({ navigation }) => {
|
||||
</Box>
|
||||
)}
|
||||
{/* 未读标记 */}
|
||||
{!notification.isRead && (
|
||||
{!item.isRead && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={-2}
|
||||
@@ -216,7 +307,7 @@ const Notifications = ({ navigation }) => {
|
||||
w={3}
|
||||
h={3}
|
||||
rounded="full"
|
||||
bg="primary.500"
|
||||
bg={isAnnouncement ? '#D4AF37' : 'primary.500'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -224,29 +315,44 @@ const Notifications = ({ navigation }) => {
|
||||
{/* 内容 */}
|
||||
<VStack flex={1} ml={3}>
|
||||
<HStack alignItems="center" flexWrap="wrap">
|
||||
{notification.senderName && (
|
||||
<Text fontSize="sm" fontWeight="bold" color="white" mr={1}>
|
||||
{notification.senderName}
|
||||
</Text>
|
||||
{isAnnouncement ? (
|
||||
<HStack alignItems="center" space={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="#D4AF37">
|
||||
{config.label}
|
||||
</Text>
|
||||
{item.title && (
|
||||
<Text fontSize="sm" fontWeight="bold" color="white" numberOfLines={1}>
|
||||
· {item.title}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
) : (
|
||||
<>
|
||||
{item.senderName && (
|
||||
<Text fontSize="sm" fontWeight="bold" color="white" mr={1}>
|
||||
{item.senderName}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
{config.label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
{config.label}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{notification.content && (
|
||||
{item.content && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.300"
|
||||
numberOfLines={2}
|
||||
mt={1}
|
||||
>
|
||||
{notification.content}
|
||||
{item.content}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text fontSize="2xs" color="gray.500" mt={1}>
|
||||
{formatRelativeTime(notification.createdAt)}
|
||||
{formatRelativeTime(item.createdAt)}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -260,12 +366,12 @@ const Notifications = ({ navigation }) => {
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading.notifications) {
|
||||
if (loading.notifications || loading.announcements) {
|
||||
return (
|
||||
<Center flex={1} py={20}>
|
||||
<Spinner size="lg" color="primary.500" />
|
||||
<Text fontSize="sm" color="gray.500" mt={4}>
|
||||
加载通知...
|
||||
加载消息...
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
@@ -275,7 +381,7 @@ const Notifications = ({ navigation }) => {
|
||||
<Center flex={1} py={20}>
|
||||
<Icon as={Ionicons} name="notifications-outline" size="4xl" color="gray.600" mb={4} />
|
||||
<Text fontSize="md" color="gray.400">
|
||||
暂无通知
|
||||
暂无消息
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
当有人回复或提及你时会收到通知
|
||||
@@ -286,9 +392,10 @@ const Notifications = ({ navigation }) => {
|
||||
|
||||
// 渲染加载更多
|
||||
const renderFooter = () => {
|
||||
if (!notificationsHasMore || notifications.length === 0) return null;
|
||||
const hasMore = notificationsHasMore || announcementsHasMore;
|
||||
if (!hasMore || mergedItems.length === 0) return null;
|
||||
|
||||
if (loading.notifications) {
|
||||
if (loading.notifications || loading.announcements) {
|
||||
return (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
@@ -300,11 +407,11 @@ const Notifications = ({ navigation }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flex={1} bg="#0F172A">
|
||||
<Box flex={1} bg="#0F172A" pt={canGoBack ? insets.top : 0}>
|
||||
<FlatList
|
||||
data={notifications || []}
|
||||
renderItem={renderNotificationItem}
|
||||
keyExtractor={(item, index) => item?.id || `notification-${index}`}
|
||||
data={mergedItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => item?.id || `item-${index}`}
|
||||
ListHeaderComponent={renderHeader}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
|
||||
@@ -551,6 +551,98 @@ export const uploadService = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 公告服务 - 获取和管理官方公告
|
||||
*/
|
||||
export const announcementService = {
|
||||
/**
|
||||
* 获取公告列表
|
||||
* @param {object} options - 分页和筛选选项
|
||||
* @param {number} options.page - 页码,默认 1
|
||||
* @param {number} options.limit - 每页数量,默认 20
|
||||
* @param {boolean} options.unreadOnly - 是否只显示未读,默认 false
|
||||
* @returns {Promise<{data: Array, hasMore: boolean, unreadCount: number}>}
|
||||
*/
|
||||
getAnnouncements: async (options = {}) => {
|
||||
try {
|
||||
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_URL}/api/social/announcements?${params}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[SocialService] getAnnouncements 失败,返回空列表');
|
||||
return { data: [], hasMore: false, unreadCount: 0 };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = result.data || {};
|
||||
|
||||
return {
|
||||
data: data.list || [],
|
||||
hasMore: data.hasMore || false,
|
||||
unreadCount: data.unreadCount || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[SocialService] getAnnouncements 错误:', error);
|
||||
return { data: [], hasMore: false, unreadCount: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记公告为已读
|
||||
* @param {number} announcementId - 公告 ID
|
||||
*/
|
||||
markAsRead: async (announcementId) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/social/announcements/${announcementId}/read`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('标记已读失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[SocialService] markAnnouncementAsRead 错误:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 标记所有公告为已读
|
||||
*/
|
||||
markAllAsRead: async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/social/announcements/read-all`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('标记全部已读失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[SocialService] markAllAnnouncementsAsRead 错误:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知服务 - 使用后端 API
|
||||
*/
|
||||
@@ -757,6 +849,7 @@ export default {
|
||||
postService,
|
||||
memberService,
|
||||
notificationService,
|
||||
announcementService,
|
||||
uploadService,
|
||||
CHANNEL_TYPES,
|
||||
CHANNEL_CATEGORIES,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
postService,
|
||||
memberService,
|
||||
notificationService,
|
||||
announcementService,
|
||||
} from '../../services/socialService';
|
||||
|
||||
// ============ Async Thunks ============
|
||||
@@ -174,6 +175,50 @@ export const markAllNotificationsRead = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
// 获取公告列表
|
||||
export const fetchAnnouncements = createAsyncThunk(
|
||||
'social/fetchAnnouncements',
|
||||
async (options = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await announcementService.getAnnouncements(options);
|
||||
return {
|
||||
announcements: response.data || [],
|
||||
hasMore: response.hasMore,
|
||||
unreadCount: response.unreadCount || 0,
|
||||
page: options.page || 1,
|
||||
};
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 标记公告已读
|
||||
export const markAnnouncementRead = createAsyncThunk(
|
||||
'social/markAnnouncementRead',
|
||||
async (announcementId, { rejectWithValue }) => {
|
||||
try {
|
||||
await announcementService.markAsRead(announcementId);
|
||||
return announcementId;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 标记所有公告已读
|
||||
export const markAllAnnouncementsRead = createAsyncThunk(
|
||||
'social/markAllAnnouncementsRead',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
await announcementService.markAllAsRead();
|
||||
return true;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============ Initial State ============
|
||||
|
||||
const initialState = {
|
||||
@@ -201,6 +246,11 @@ const initialState = {
|
||||
notificationsHasMore: true,
|
||||
unreadCount: 0,
|
||||
|
||||
// 公告相关
|
||||
announcements: [],
|
||||
announcementsHasMore: true,
|
||||
announcementUnreadCount: 0,
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
channels: false,
|
||||
@@ -210,6 +260,7 @@ const initialState = {
|
||||
replies: false,
|
||||
members: false,
|
||||
notifications: false,
|
||||
announcements: false,
|
||||
sending: false,
|
||||
},
|
||||
|
||||
@@ -318,6 +369,16 @@ const socialSlice = createSlice({
|
||||
state.unreadCount += 1;
|
||||
},
|
||||
|
||||
// 设置公告未读数量
|
||||
setAnnouncementUnreadCount: (state, action) => {
|
||||
state.announcementUnreadCount = action.payload;
|
||||
},
|
||||
|
||||
// 增加公告未读数量
|
||||
incrementAnnouncementUnreadCount: (state) => {
|
||||
state.announcementUnreadCount += 1;
|
||||
},
|
||||
|
||||
// 清除错误
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
@@ -495,6 +556,47 @@ const socialSlice = createSlice({
|
||||
n.isRead = true;
|
||||
});
|
||||
state.unreadCount = 0;
|
||||
})
|
||||
|
||||
// 获取公告
|
||||
.addCase(fetchAnnouncements.pending, (state) => {
|
||||
state.loading.announcements = true;
|
||||
})
|
||||
.addCase(fetchAnnouncements.fulfilled, (state, action) => {
|
||||
state.loading.announcements = false;
|
||||
const { announcements, hasMore, unreadCount, page } = action.payload;
|
||||
// 第一页替换,后续页追加
|
||||
if (page === 1) {
|
||||
state.announcements = announcements;
|
||||
} else {
|
||||
state.announcements = [...state.announcements, ...announcements];
|
||||
}
|
||||
state.announcementsHasMore = hasMore;
|
||||
if (typeof unreadCount === 'number') {
|
||||
state.announcementUnreadCount = unreadCount;
|
||||
}
|
||||
})
|
||||
.addCase(fetchAnnouncements.rejected, (state, action) => {
|
||||
state.loading.announcements = false;
|
||||
state.error = action.payload;
|
||||
})
|
||||
|
||||
// 标记公告已读
|
||||
.addCase(markAnnouncementRead.fulfilled, (state, action) => {
|
||||
const announcementId = action.payload;
|
||||
const announcement = state.announcements.find(a => a.id === announcementId);
|
||||
if (announcement && !announcement.isRead) {
|
||||
announcement.isRead = true;
|
||||
state.announcementUnreadCount = Math.max(0, state.announcementUnreadCount - 1);
|
||||
}
|
||||
})
|
||||
|
||||
// 标记所有公告已读
|
||||
.addCase(markAllAnnouncementsRead.fulfilled, (state) => {
|
||||
state.announcements.forEach(a => {
|
||||
a.isRead = true;
|
||||
});
|
||||
state.announcementUnreadCount = 0;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -513,6 +615,8 @@ export const {
|
||||
removeTypingUser,
|
||||
setUnreadCount,
|
||||
incrementUnreadCount,
|
||||
setAnnouncementUnreadCount,
|
||||
incrementAnnouncementUnreadCount,
|
||||
clearError,
|
||||
resetSocialState,
|
||||
} = socialSlice.actions;
|
||||
|
||||
@@ -206,11 +206,12 @@ def send_social_notification(notification, device_tokens: List[str]) -> dict:
|
||||
|
||||
# 根据通知类型设置标题
|
||||
type_labels = {
|
||||
'announcement': '📣 官方公告',
|
||||
'reply': '💬 新回复',
|
||||
'mention': '📢 有人@你',
|
||||
'like': '❤️ 收到点赞',
|
||||
'follow': '👋 新关注',
|
||||
'system': '📣 系统通知',
|
||||
'system': '🔔 系统通知',
|
||||
'message': '💬 新消息',
|
||||
}
|
||||
title = type_labels.get(notif_type, '📣 新通知')
|
||||
|
||||
407
app.py
407
app.py
@@ -1619,6 +1619,64 @@ class SocialNotification(db.Model):
|
||||
}
|
||||
|
||||
|
||||
class Announcement(db.Model):
|
||||
"""官方公告表"""
|
||||
__tablename__ = 'announcements'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=True)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
target_url = db.Column(db.String(500), nullable=True) # 点击跳转链接
|
||||
|
||||
# 发布者信息
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
|
||||
|
||||
# 状态
|
||||
is_active = db.Column(db.Boolean, default=True, index=True) # 是否有效(可用于软删除)
|
||||
is_pushed = db.Column(db.Boolean, default=False) # 是否已推送
|
||||
|
||||
# 时间
|
||||
created_at = db.Column(db.DateTime, default=beijing_now, index=True)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
# 统计
|
||||
read_count = db.Column(db.Integer, default=0) # 已读人数
|
||||
|
||||
# 关系
|
||||
creator = db.relationship('User', backref='created_announcements')
|
||||
|
||||
def to_dict(self, user_read_status=None):
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'content': self.content,
|
||||
'targetUrl': self.target_url,
|
||||
'createdBy': self.created_by,
|
||||
'createdAt': self.created_at.isoformat() if self.created_at else None,
|
||||
'readCount': self.read_count,
|
||||
'isRead': user_read_status, # 当前用户是否已读
|
||||
}
|
||||
|
||||
|
||||
class AnnouncementReadStatus(db.Model):
|
||||
"""公告已读状态表"""
|
||||
__tablename__ = 'announcement_read_status'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
|
||||
announcement_id = db.Column(db.Integer, db.ForeignKey('announcements.id'), nullable=False, index=True)
|
||||
read_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 联合唯一索引
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('user_id', 'announcement_id', name='uix_user_announcement'),
|
||||
)
|
||||
|
||||
# 关系
|
||||
user = db.relationship('User', backref='announcement_reads')
|
||||
announcement = db.relationship('Announcement', backref='read_statuses')
|
||||
|
||||
|
||||
class SubscriptionPlan(db.Model):
|
||||
"""订阅套餐表"""
|
||||
__tablename__ = 'subscription_plans'
|
||||
@@ -21547,6 +21605,355 @@ def send_social_notification_api():
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 官方公告 API ====================
|
||||
|
||||
@app.route('/api/social/announcements', methods=['GET'])
|
||||
@login_required
|
||||
def get_announcements():
|
||||
"""
|
||||
获取公告列表(用户端)
|
||||
返回所有有效公告及当前用户的已读状态
|
||||
|
||||
查询参数:
|
||||
- page: 页码,默认 1
|
||||
- limit: 每页数量,默认 20
|
||||
- unread_only: 是否只看未读,默认 false
|
||||
"""
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = min(request.args.get('limit', 20, type=int), 50)
|
||||
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
|
||||
|
||||
# 查询有效公告
|
||||
query = db.session.query(Announcement).filter(
|
||||
Announcement.is_active == True
|
||||
)
|
||||
|
||||
# 如果只看未读,排除已读的公告
|
||||
if unread_only:
|
||||
read_announcement_ids = db.session.query(
|
||||
AnnouncementReadStatus.announcement_id
|
||||
).filter(
|
||||
AnnouncementReadStatus.user_id == current_user.id
|
||||
).subquery()
|
||||
query = query.filter(~Announcement.id.in_(read_announcement_ids))
|
||||
|
||||
# 按创建时间倒序
|
||||
query = query.order_by(Announcement.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
announcements = query.offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
# 获取当前用户对这些公告的已读状态
|
||||
announcement_ids = [a.id for a in announcements]
|
||||
read_status_query = db.session.query(
|
||||
AnnouncementReadStatus.announcement_id
|
||||
).filter(
|
||||
AnnouncementReadStatus.user_id == current_user.id,
|
||||
AnnouncementReadStatus.announcement_id.in_(announcement_ids)
|
||||
).all()
|
||||
read_ids = {rs[0] for rs in read_status_query}
|
||||
|
||||
# 统计未读数量
|
||||
unread_count = db.session.query(Announcement).filter(
|
||||
Announcement.is_active == True,
|
||||
~Announcement.id.in_(
|
||||
db.session.query(AnnouncementReadStatus.announcement_id).filter(
|
||||
AnnouncementReadStatus.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
).count()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'list': [
|
||||
a.to_dict(user_read_status=(a.id in read_ids))
|
||||
for a in announcements
|
||||
],
|
||||
'total': total,
|
||||
'hasMore': page * limit < total,
|
||||
'unreadCount': unread_count
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"获取公告列表失败: {e}")
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/social/announcements', methods=['POST'])
|
||||
@login_required
|
||||
def publish_announcement():
|
||||
"""
|
||||
发布官方公告(管理员专用)
|
||||
创建一条公告记录并推送给所有用户
|
||||
|
||||
请求体:
|
||||
{
|
||||
"title": "公告标题",
|
||||
"content": "公告内容",
|
||||
"target_url": "可选,点击跳转的链接",
|
||||
"push_to_all": true // 是否推送给所有用户,默认 true
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 检查管理员权限(user_id=1 或有 admin 标记)
|
||||
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
|
||||
return jsonify({'code': 403, 'message': '无权限发布公告'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'message': '缺少请求体'}), 400
|
||||
|
||||
title = data.get('title', '').strip()
|
||||
content = data.get('content', '').strip()
|
||||
target_url = data.get('target_url')
|
||||
push_to_all = data.get('push_to_all', True)
|
||||
|
||||
if not title and not content:
|
||||
return jsonify({'code': 400, 'message': '标题和内容不能都为空'}), 400
|
||||
|
||||
# 创建公告记录
|
||||
announcement = Announcement(
|
||||
title=title or None,
|
||||
content=content,
|
||||
target_url=target_url,
|
||||
created_by=current_user.id,
|
||||
is_active=True,
|
||||
is_pushed=push_to_all,
|
||||
)
|
||||
db.session.add(announcement)
|
||||
db.session.commit()
|
||||
|
||||
print(f'[公告] 已创建公告 ID={announcement.id}')
|
||||
|
||||
# 组合推送内容
|
||||
notification_content = f"【{title}】{content}" if title else content
|
||||
|
||||
# 推送通知
|
||||
push_result = {'success': 0, 'failed': 0}
|
||||
if push_to_all:
|
||||
try:
|
||||
from apns_push import send_push_notification
|
||||
|
||||
# 获取所有活跃设备 token
|
||||
device_tokens_query = db.session.query(UserDeviceToken.device_token).filter(
|
||||
UserDeviceToken.is_active == True
|
||||
).all()
|
||||
tokens = [t[0] for t in device_tokens_query]
|
||||
|
||||
if tokens:
|
||||
print(f'[公告 APNs] 准备推送到 {len(tokens)} 个设备')
|
||||
push_result = send_push_notification(
|
||||
device_tokens=tokens,
|
||||
title='📣 官方公告',
|
||||
body=notification_content[:100] + ('...' if len(notification_content) > 100 else ''),
|
||||
data={
|
||||
'type': 'announcement',
|
||||
'announcement_id': announcement.id,
|
||||
'title': title,
|
||||
'target_url': target_url
|
||||
},
|
||||
badge=1,
|
||||
priority=10
|
||||
)
|
||||
print(f'[公告 APNs] 推送结果: 成功 {push_result["success"]}, 失败 {push_result["failed"]}')
|
||||
else:
|
||||
print('[公告 APNs] 没有活跃的设备 token')
|
||||
|
||||
except ImportError as e:
|
||||
print(f'[公告 APNs WARN] apns_push 模块未安装: {e}')
|
||||
except Exception as apns_error:
|
||||
print(f'[公告 APNs ERROR] 推送失败: {apns_error}')
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '公告已发布',
|
||||
'data': {
|
||||
'id': announcement.id,
|
||||
'title': title,
|
||||
'content': content,
|
||||
'push_success': push_result.get('success', 0),
|
||||
'push_failed': push_result.get('failed', 0)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"发布公告失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/social/announcements/<int:announcement_id>/read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_announcement_read(announcement_id):
|
||||
"""
|
||||
标记公告为已读
|
||||
"""
|
||||
try:
|
||||
# 检查公告是否存在
|
||||
announcement = db.session.query(Announcement).filter(
|
||||
Announcement.id == announcement_id,
|
||||
Announcement.is_active == True
|
||||
).first()
|
||||
|
||||
if not announcement:
|
||||
return jsonify({'code': 404, 'message': '公告不存在'}), 404
|
||||
|
||||
# 检查是否已经标记过
|
||||
existing = db.session.query(AnnouncementReadStatus).filter(
|
||||
AnnouncementReadStatus.user_id == current_user.id,
|
||||
AnnouncementReadStatus.announcement_id == announcement_id
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
# 创建已读记录
|
||||
read_status = AnnouncementReadStatus(
|
||||
user_id=current_user.id,
|
||||
announcement_id=announcement_id
|
||||
)
|
||||
db.session.add(read_status)
|
||||
|
||||
# 更新公告已读计数
|
||||
announcement.read_count = (announcement.read_count or 0) + 1
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '已标记为已读'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"标记公告已读失败: {e}")
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/social/announcements/read-all', methods=['POST'])
|
||||
@login_required
|
||||
def mark_all_announcements_read():
|
||||
"""
|
||||
标记所有公告为已读
|
||||
"""
|
||||
try:
|
||||
# 获取用户尚未读取的公告
|
||||
read_ids = db.session.query(AnnouncementReadStatus.announcement_id).filter(
|
||||
AnnouncementReadStatus.user_id == current_user.id
|
||||
).subquery()
|
||||
|
||||
unread_announcements = db.session.query(Announcement).filter(
|
||||
Announcement.is_active == True,
|
||||
~Announcement.id.in_(read_ids)
|
||||
).all()
|
||||
|
||||
count = 0
|
||||
for announcement in unread_announcements:
|
||||
read_status = AnnouncementReadStatus(
|
||||
user_id=current_user.id,
|
||||
announcement_id=announcement.id
|
||||
)
|
||||
db.session.add(read_status)
|
||||
announcement.read_count = (announcement.read_count or 0) + 1
|
||||
count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': f'已标记 {count} 条公告为已读',
|
||||
'data': {'marked_count': count}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"标记所有公告已读失败: {e}")
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/social/announcements/history', methods=['GET'])
|
||||
@login_required
|
||||
def get_announcement_history():
|
||||
"""
|
||||
获取公告历史(管理员查看)
|
||||
"""
|
||||
try:
|
||||
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
|
||||
return jsonify({'code': 403, 'message': '无权限'}), 403
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
|
||||
# 从 Announcement 表查询
|
||||
query = db.session.query(Announcement).order_by(
|
||||
Announcement.created_at.desc()
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
announcements = query.offset((page - 1) * limit).limit(limit).all()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'announcements': [
|
||||
{
|
||||
'id': a.id,
|
||||
'title': a.title,
|
||||
'content': a.content,
|
||||
'target_url': a.target_url,
|
||||
'created_at': a.created_at.isoformat() if a.created_at else None,
|
||||
'read_count': a.read_count or 0,
|
||||
'is_active': a.is_active,
|
||||
'is_pushed': a.is_pushed
|
||||
}
|
||||
for a in announcements
|
||||
],
|
||||
'total': total,
|
||||
'hasMore': page * limit < total
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"获取公告历史失败: {e}")
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/social/announcements/<int:announcement_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_announcement(announcement_id):
|
||||
"""
|
||||
删除公告(软删除,管理员专用)
|
||||
"""
|
||||
try:
|
||||
if current_user.id != 1 and not getattr(current_user, 'is_admin', False):
|
||||
return jsonify({'code': 403, 'message': '无权限'}), 403
|
||||
|
||||
announcement = db.session.query(Announcement).filter(
|
||||
Announcement.id == announcement_id
|
||||
).first()
|
||||
|
||||
if not announcement:
|
||||
return jsonify({'code': 404, 'message': '公告不存在'}), 404
|
||||
|
||||
# 软删除
|
||||
announcement.is_active = False
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'message': '公告已删除'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"删除公告失败: {e}")
|
||||
return jsonify({'code': 500, 'message': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
|
||||
Reference in New Issue
Block a user