Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
540
src/screens/message/NotificationsScreen.tsx
Normal file
540
src/screens/message/NotificationsScreen.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 通知页 NotificationsScreen
|
||||
* 胡萝卜BBS - 系统消息列表
|
||||
* 使用新的系统消息API
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { commentService } from '../../services/commentService';
|
||||
import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||
|
||||
const MESSAGE_TYPES = [
|
||||
{ key: 'all', title: '全部' },
|
||||
{ key: 'like_post', title: '点赞' },
|
||||
{ key: 'comment', title: '评论' },
|
||||
{ key: 'follow', title: '关注' },
|
||||
{ key: 'group', title: '群通知' },
|
||||
{ key: 'system', title: '系统' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
];
|
||||
|
||||
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
|
||||
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
|
||||
|
||||
export const NotificationsScreen: React.FC = () => {
|
||||
const isFocused = useIsFocused();
|
||||
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
||||
const [activeType, setActiveType] = useState('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
||||
const displayMessages = useMemo(() => {
|
||||
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
||||
messages.forEach((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return;
|
||||
const flag = msg.extra_data?.flag;
|
||||
const status = msg.extra_data?.request_status;
|
||||
if (!flag) return;
|
||||
if (status === 'accepted' || status === 'rejected') {
|
||||
reviewedByFlag.set(flag, msg);
|
||||
}
|
||||
});
|
||||
|
||||
if (reviewedByFlag.size === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return msg;
|
||||
const flag = msg.extra_data?.flag;
|
||||
if (!flag) return msg;
|
||||
const reviewedMsg = reviewedByFlag.get(flag);
|
||||
if (!reviewedMsg) return msg;
|
||||
const reviewedStatus = reviewedMsg.extra_data?.request_status;
|
||||
if (reviewedStatus !== 'accepted' && reviewedStatus !== 'rejected') return msg;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
extra_data: {
|
||||
...msg.extra_data,
|
||||
request_status: reviewedStatus,
|
||||
actor_name: reviewedMsg.extra_data?.actor_name || reviewedMsg.extra_data?.operator_name || msg.extra_data?.actor_name,
|
||||
avatar_url: reviewedMsg.extra_data?.avatar_url || reviewedMsg.extra_data?.operator_avatar || msg.extra_data?.avatar_url,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// 获取系统消息数据
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await messageService.getSystemMessages(50, 1);
|
||||
// 添加防御性检查,确保 messages 数组存在
|
||||
setMessages(response.messages || []);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('获取系统消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取未读数
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await messageService.getSystemUnreadCount();
|
||||
setUnreadCount(response.unread_count);
|
||||
} catch (error) {
|
||||
console.error('获取未读数失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 一键已读
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
await messageService.markAllSystemMessagesRead();
|
||||
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
setSystemUnreadCount(0);
|
||||
// 同步更新全局 TabBar 红点
|
||||
fetchMessageUnreadCount();
|
||||
// 刷新消息列表
|
||||
fetchMessages();
|
||||
} catch (error) {
|
||||
console.error('一键已读失败:', error);
|
||||
}
|
||||
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
|
||||
|
||||
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
fetchMessages();
|
||||
fetchUnreadCount();
|
||||
// 进入界面自动标记所有消息为已读
|
||||
handleMarkAllRead();
|
||||
}
|
||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
||||
|
||||
// 屏幕失去焦点时返回消息列表
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
// 使用 setTimeout 确保在导航状态稳定后再执行
|
||||
const timer = setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFocused, navigation]);
|
||||
|
||||
// 筛选消息
|
||||
const filteredMessages = activeType === 'all'
|
||||
? displayMessages
|
||||
: displayMessages.filter((m) => {
|
||||
if (activeType === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (activeType === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === activeType;
|
||||
});
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([fetchMessages(), fetchUnreadCount()]);
|
||||
setRefreshing(false);
|
||||
}, [fetchMessages, fetchUnreadCount]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || messages.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
// 使用时间戳或seq作为游标分页(后端使用page分页)
|
||||
const nextPage = Math.floor((messages.length / 20)) + 1;
|
||||
const response = await messageService.getSystemMessages(20, nextPage);
|
||||
// 添加防御性检查
|
||||
const newMessages = response.messages || [];
|
||||
setMessages(prev => [...prev, ...newMessages]);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('加载更多失败:', error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, messages]);
|
||||
|
||||
// 标记单条消息已读并处理导航
|
||||
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
|
||||
if (!actionUrl) return null;
|
||||
|
||||
const postPathMatch = actionUrl.match(/\/posts\/([^/?#]+)/);
|
||||
if (postPathMatch?.[1]) {
|
||||
return decodeURIComponent(postPathMatch[1]);
|
||||
}
|
||||
|
||||
const query = actionUrl.split('?')[1];
|
||||
if (!query) return null;
|
||||
const params = new URLSearchParams(query);
|
||||
return params.get('post') || params.get('post_id');
|
||||
};
|
||||
|
||||
const resolvePostId = async (message: SystemMessageResponse): Promise<string | null> => {
|
||||
const { extra_data, system_type } = message;
|
||||
|
||||
// 后端已统一将 target_id 设为帖子ID(like_post/comment/mention/favorite_post/like_reply/like_comment)
|
||||
if (
|
||||
extra_data?.target_id &&
|
||||
['like_post', 'like_comment', 'like_reply', 'comment', 'mention', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
return extra_data.target_id;
|
||||
}
|
||||
|
||||
// reply 通知:target_id 是 replyID,从 action_url 解析帖子ID
|
||||
const postIdFromUrl = extractPostIdFromActionUrl(extra_data?.action_url);
|
||||
if (postIdFromUrl) {
|
||||
return postIdFromUrl;
|
||||
}
|
||||
|
||||
// 兜底:通过评论详情反查(仅旧数据兜底)
|
||||
if (extra_data?.target_id && system_type === 'reply') {
|
||||
const comment = await commentService.getComment(extra_data.target_id);
|
||||
if (comment?.post_id) {
|
||||
return comment.post_id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleMessagePress = async (message: SystemMessageResponse) => {
|
||||
try {
|
||||
const messageId = String(message.id);
|
||||
const wasUnread = message.is_read !== true;
|
||||
await messageService.markSystemMessageRead(messageId);
|
||||
setMessages(prev =>
|
||||
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
|
||||
);
|
||||
if (wasUnread) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
decrementSystemUnreadCount(1);
|
||||
}
|
||||
// 更新本地未读数以及全局 TabBar 红点
|
||||
fetchUnreadCount();
|
||||
fetchMessageUnreadCount();
|
||||
|
||||
// 根据消息类型处理导航
|
||||
const { system_type, extra_data } = message;
|
||||
|
||||
if (
|
||||
['like_post', 'comment', 'mention', 'reply', 'like_comment', 'like_reply', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
const postId = await resolvePostId(message);
|
||||
if (postId) {
|
||||
navigation.navigate('PostDetail', { postId });
|
||||
}
|
||||
} else if (system_type === 'follow') {
|
||||
// 关注 - 跳转到用户主页
|
||||
if (extra_data?.actor_id_str) {
|
||||
navigation.navigate('UserProfile', { userId: extra_data.actor_id_str });
|
||||
}
|
||||
} else if (system_type === 'group_join_apply') {
|
||||
navigation.navigate('GroupRequestDetail', { message });
|
||||
} else if (system_type === 'group_invite') {
|
||||
navigation.navigate('GroupInviteDetail', { message });
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理头像点击 - 跳转到用户主页
|
||||
const handleAvatarPress = (message: SystemMessageResponse) => {
|
||||
const { extra_data } = message;
|
||||
// 优先使用 actor_id_str (UUID格式),兼容 actor_id (数字格式)
|
||||
const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null);
|
||||
|
||||
if (actorId) {
|
||||
navigation.navigate('UserProfile', { userId: actorId });
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息项
|
||||
const renderMessage = ({ item }: { item: SystemMessageResponse }) => (
|
||||
<SystemMessageItem
|
||||
message={item}
|
||||
onPress={() => handleMessagePress(item)}
|
||||
onAvatarPress={() => handleAvatarPress(item)}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = () => {
|
||||
if (!loadingMore) return null;
|
||||
return (
|
||||
<View style={styles.loadingMore}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.loadingMoreText}>
|
||||
加载中...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => (
|
||||
<EmptyState
|
||||
title="暂无通知"
|
||||
description="关注一些用户来获取通知吧"
|
||||
icon="bell-outline"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
isWideScreen && styles.filterTagWide,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<>
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
filterContainerWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginRight: spacing.sm,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterTagWide: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
filterTagActive: {
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
},
|
||||
filterCount: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
listContentWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl * 2,
|
||||
},
|
||||
loadingMore: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
loadingMoreText: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user