Files
frontend/src/screens/message/NotificationsScreen.tsx
lafay 57d7c7405c
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m29s
Frontend CI / ota-android (push) Successful in 10m35s
Frontend CI / build-android-apk (push) Successful in 37m48s
refactor(stores): reorganize flat store files into modular directory structure
Migrate from flat file organization to modular directory structure under src/stores/:

- auth/: authStore, registerStore, verificationStore, sessionStore
- call/: callStore
- settings/: chatSettingsStore, themeStore
- ui/: homeTabBarVisibilityStore, homeTabPressStore
- utils/: routePayloadCache
- group/sources.ts, group/profileResolver.ts
- message/sources.ts
- post/sources.ts

Update all import paths across components and screens to use new module paths. Maintain backward compatibility through deprecated re-export files for gradual migration.
2026-04-13 04:56:58 +08:00

785 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 通知页 NotificationsScreen扁平化风格
* 胡萝卜BBS - 系统消息列表
* 【游标分页】使用 messageService.getSystemMessagesCursor
* 支持响应式布局
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 简洁的标题区域
* - 分段式筛选标签
* - 与登录、注册、设置页面风格一致
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
RefreshControl,
ActivityIndicator,
Dimensions,
BackHandler,
Animated,
StatusBar,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useIsFocused } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, borderRadius, useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '@/services/message';
import { commentService } from '@/services/post';
import { SystemMessageItem } from '../../components/business';
import { Text, ResponsiveContainer } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores';
import { messageManager } from '../../stores/message';
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<{ onBack?: () => void }> = ({ onBack }) => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createNotificationsStyles(colors), [colors]);
const isFocused = useIsFocused();
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const router = useRouter();
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
// 延迟设置窗口尺寸,确保在客户端完全加载后再获取
const timer = setTimeout(() => {
const { width, height } = Dimensions.get('window');
setWindowSize({ width, height });
setIsHydrated(true);
}, 100);
return () => clearTimeout(timer);
}, []);
// 使用本地尺寸状态计算断点 (lg = 768)
const isWideScreen = windowSize.width >= 768;
const isDesktop = windowSize.width >= 1024;
const isTablet = windowSize.width >= 768 && windowSize.width < 1024;
// Web端使用更大的容器宽度
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
const [activeType, setActiveType] = useState('all');
const [unreadCount, setUnreadCount] = useState(0);
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表
// 使用 useCallback 包裹 fetchFunction避免无限重新创建导致循环
const fetchSystemMessages = useCallback(
async ({ cursor, pageSize }: { cursor?: string; pageSize: number }) => {
return await messageService.getSystemMessagesCursor({
cursor,
page_size: pageSize,
});
},
[]
);
const {
list: messages,
isLoading,
isRefreshing,
hasMore,
loadMore,
refresh,
error: paginationError,
} = useCursorPagination(fetchSystemMessages, { pageSize: 20 });
// 本地刷新状态仅用于下拉刷新的UI显示
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// 同一 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 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();
// 刷新消息列表
await refresh();
setUnreadCount(0);
setSystemUnreadCount(0);
// 同步更新全局 TabBar 红点
messageManager.fetchUnreadCount();
} catch (error) {
console.error('一键已读失败:', error);
}
}, [refresh, setSystemUnreadCount]);
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
// 使用 ref 防止重复执行,避免无限循环
const hasInitializedRef = useRef(false);
useEffect(() => {
if (isFocused && !hasInitializedRef.current) {
hasInitializedRef.current = true;
fetchUnreadCount();
// 进入界面自动标记所有消息为已读
handleMarkAllRead();
}
}, [isFocused]); // 只依赖 isFocused函数使用 ref 稳定引用
// 启动入场动画
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => {
if (!isFocused && onBack) {
onBack();
}
}, [isFocused, onBack]);
// 处理 Android 物理返回键 - 当 onBack 存在时(内嵌模式),拦截返回键
useEffect(() => {
if (!onBack) return;
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (isFocused) {
onBack();
return true; // 阻止默认行为
}
return false;
});
return () => backHandler.remove();
}, [isFocused, onBack]);
// 筛选消息
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([refresh(), fetchUnreadCount()]);
setRefreshing(false);
}, [refresh, fetchUnreadCount]);
// 加载更多
const onEndReached = useCallback(async () => {
if (isLoading || loadingMore || !hasMore) return;
setLoadingMore(true);
await loadMore();
setLoadingMore(false);
}, [isLoading, loadingMore, hasMore, loadMore]);
// 标记单条消息已读并处理导航
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 设为帖子IDlike_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);
// 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据
if (wasUnread) {
setUnreadCount(prev => Math.max(0, prev - 1));
decrementSystemUnreadCount(1);
}
// 更新本地未读数以及全局 TabBar 红点
fetchUnreadCount();
messageManager.fetchUnreadCount();
// 根据消息类型处理导航
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) {
router.push(hrefs.hrefPostDetail(postId));
}
} else if (system_type === 'follow') {
// 关注 - 跳转到用户主页
if (extra_data?.actor_id_str) {
router.push(hrefs.hrefUserProfile(extra_data.actor_id_str));
}
} else if (system_type === 'group_join_apply') {
router.push(hrefs.hrefGroupRequestDetail(message));
} else if (system_type === 'group_invite') {
router.push(hrefs.hrefGroupInviteDetail(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) {
router.push(hrefs.hrefUserProfile(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 renderHeader = () => {
// 大屏模式(>= lg 断点)隐藏返回按钮
const shouldShowBackButton = onBack && !isWideScreen;
return (
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? (
<TouchableOpacity style={styles.backButton} onPress={onBack} activeOpacity={0.8}>
<View style={styles.backIconButton}>
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.text.primary} />
</View>
</TouchableOpacity>
) : (
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
</Text>
{unreadCount > 0 && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
)}
</View>
<View style={styles.backButtonPlaceholder} />
</View>
);
};
// 渲染空状态 - 扁平化风格
const renderEmpty = () => (
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
<View style={styles.emptyIconContainer}>
<MaterialCommunityIcons name="bell-outline" size={64} color={colors.text.hint} />
</View>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyDescription}></Text>
</View>
);
// 如果还未hydrated显示加载占位符避免竞态条件导致的渲染问题
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{isWideScreen ? (
<ResponsiveContainer maxWidth={containerMaxWidth}>
{/* 头部 - 大屏模式下隐藏返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
{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;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
isWideScreen && styles.filterTagWide,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<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={onEndReached}
onEndReachedThreshold={0.3}
scrollEnabled={true}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</ResponsiveContainer>
) : (
<>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */}
<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;
const isActive = activeType === type.key;
return (
<TouchableOpacity
key={type.key}
style={[
styles.filterTag,
isActive && styles.filterTagActive,
]}
onPress={() => setActiveType(type.key)}
activeOpacity={0.8}
>
<Text
variant="body"
color={isActive ? colors.text.inverse : colors.text.secondary}
style={isActive ? styles.filterTagTextActive : undefined}
>
{type.title}
</Text>
{count > 0 && (
<View style={[styles.filterCount, isActive && styles.filterCountActive]}>
<Text
variant="caption"
color={isActive ? colors.text.inverse : colors.text.hint}
>
{count}
</Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<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={onEndReached}
onEndReachedThreshold={0.3}
scrollEnabled={true}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
)}
</Animated.View>
</SafeAreaView>
);
};
function createNotificationsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
content: {
flex: 1,
},
// Header 样式 - 扁平化风格
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: colors.background.paper,
},
headerWide: {
paddingHorizontal: 32,
paddingVertical: 20,
},
headerTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text.primary,
},
headerTitleWide: {
fontSize: 20,
},
unreadBadge: {
backgroundColor: colors.primary.main,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: spacing.sm,
minWidth: 20,
alignItems: 'center',
justifyContent: 'center',
},
unreadBadgeText: {
color: colors.text.inverse,
fontSize: 11,
fontWeight: '600',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
backIconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
},
backButtonPlaceholder: {
width: 40,
},
// 分类筛选 - 扁平化风格(分段式设计)
filterContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
filterContainerWideWeb: {
paddingHorizontal: 32,
paddingVertical: 16,
justifyContent: 'center',
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 8,
marginRight: 8,
borderRadius: 14,
backgroundColor: colors.background.default,
borderWidth: 1,
borderColor: 'transparent',
},
filterTagWide: {
paddingHorizontal: 18,
paddingVertical: 10,
marginRight: 10,
},
filterTagActive: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
filterTagTextActive: {
fontWeight: '600',
},
filterCount: {
marginLeft: 6,
backgroundColor: colors.primary.light + '40',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
minWidth: 18,
alignItems: 'center',
},
filterCountActive: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
listContent: {
flexGrow: 1,
paddingTop: 8,
paddingBottom: 24,
},
listContentWide: {
paddingHorizontal: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 80,
},
loadingMore: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 20,
},
loadingMoreText: {
marginLeft: 8,
},
// 空状态 - 扁平化风格
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 80,
paddingHorizontal: 40,
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: colors.background.default,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
color: colors.text.primary,
marginBottom: 8,
},
emptyDescription: {
fontSize: 15,
color: colors.text.secondary,
textAlign: 'center',
},
});
}