refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s

- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
This commit is contained in:
lafay
2026-04-04 08:01:45 +08:00
parent 189b977fac
commit 82c2970a85
76 changed files with 3382 additions and 2000 deletions

View File

@@ -1,8 +1,14 @@
/**
* 通知页 NotificationsScreen
* 通知页 NotificationsScreen(扁平化风格)
* 胡萝卜BBS - 系统消息列表
* 【游标分页】使用 messageService.getSystemMessagesCursor
* 支持响应式布局
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 简洁的标题区域
* - 分段式筛选标签
* - 与登录、注册、设置页面风格一致
*/
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
@@ -15,17 +21,19 @@ import {
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, shadows, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, useAppColors, type AppColors } 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, AppBackButton } from '../../components/common';
import { Text, ResponsiveContainer } from '../../components/common';
import { useCursorPagination } from '../../hooks/useCursorPagination';
import * as hrefs from '../../navigation/hrefs';
import { useMessageManagerSystemUnreadCount } from '../../stores';
@@ -41,6 +49,13 @@ const MESSAGE_TYPES = [
{ key: 'announcement', title: '公告' },
];
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
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']);
@@ -51,6 +66,10 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
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);
@@ -177,6 +196,22 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
}
}, [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) {
@@ -343,7 +378,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染头部(包含返回按钮)
// 渲染头部(包含返回按钮)- 扁平化风格
const renderHeader = () => {
// 大屏模式(>= lg 断点)隐藏返回按钮
const shouldShowBackButton = onBack && !isWideScreen;
@@ -351,7 +386,11 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
{shouldShowBackButton ? (
<AppBackButton style={styles.backButton} onPress={onBack} />
<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} />
)}
@@ -370,14 +409,14 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
);
};
// 渲染空状态
// 渲染空状态 - 扁平化风格
const renderEmpty = () => (
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
<EmptyState
title="暂无通知"
description="关注一些用户来获取通知吧"
icon="bell-outline"
/>
<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>
);
@@ -385,8 +424,9 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
if (!isHydrated) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
</SafeAreaView>
);
@@ -394,164 +434,175 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
{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}
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<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}
>
{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} />
<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>
) : (
<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}
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}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</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}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</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}
>
{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} />
<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>
) : (
<FlatList
data={filteredMessages}
renderItem={renderMessage}
keyExtractor={item => item.id.toString()}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
onEndReached={onEndReached}
onEndReachedThreshold={0.3}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
/>
)}
</>
)}
{/* 消息列表 */}
{isLoading && messages.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</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}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[THEME_COLORS.primary]}
tintColor={THEME_COLORS.primary}
/>
}
/>
)}
</>
)}
</Animated.View>
</SafeAreaView>
);
};
@@ -560,21 +611,23 @@ function createNotificationsStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
backgroundColor: '#fff',
},
// Header 样式 - 美化版
content: {
flex: 1,
},
// Header 样式 - 扁平化风格
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
...shadows.sm,
paddingHorizontal: 20,
paddingVertical: 16,
backgroundColor: '#fff',
},
headerWide: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
paddingHorizontal: 32,
paddingVertical: 20,
},
headerTitleContainer: {
flexDirection: 'row',
@@ -591,7 +644,7 @@ function createNotificationsStyles(colors: AppColors) {
fontSize: 20,
},
unreadBadge: {
backgroundColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
@@ -601,7 +654,7 @@ function createNotificationsStyles(colors: AppColors) {
justifyContent: 'center',
},
unreadBadgeText: {
color: colors.text.inverse,
color: '#fff',
fontSize: 11,
fontWeight: '600',
},
@@ -609,50 +662,59 @@ function createNotificationsStyles(colors: AppColors) {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'flex-start',
alignItems: 'center',
},
backIconButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
},
backButtonPlaceholder: {
width: 40,
},
// 分类筛选 - 美化版(使用分段式设计)
// 分类筛选 - 扁平化风格(分段式设计)
filterContainer: {
flexDirection: 'row',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
},
filterContainerWideWeb: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: 32,
paddingVertical: 16,
justifyContent: 'center',
backgroundColor: colors.background.paper,
},
filterTag: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginRight: spacing.xs,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
paddingHorizontal: 14,
paddingVertical: 8,
marginRight: 8,
borderRadius: 14,
backgroundColor: '#F5F5F7',
borderWidth: 1,
borderColor: 'transparent',
},
filterTagWide: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginRight: spacing.sm,
paddingHorizontal: 18,
paddingVertical: 10,
marginRight: 10,
},
filterTagActive: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
backgroundColor: THEME_COLORS.primary,
borderColor: THEME_COLORS.primary,
},
filterTagTextActive: {
fontWeight: '600',
},
filterCount: {
marginLeft: spacing.xs,
backgroundColor: colors.primary.light + '40',
marginLeft: 6,
backgroundColor: THEME_COLORS.primaryLight + '40',
paddingHorizontal: 6,
paddingVertical: 1,
borderRadius: 8,
@@ -664,37 +726,59 @@ function createNotificationsStyles(colors: AppColors) {
},
listContent: {
flexGrow: 1,
paddingTop: spacing.md,
paddingBottom: spacing.lg,
paddingTop: 8,
paddingBottom: 24,
},
listContentWide: {
paddingHorizontal: spacing.xl,
paddingHorizontal: 32,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.xl * 2,
paddingVertical: 80,
},
loadingMore: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: spacing.lg,
paddingVertical: 20,
},
loadingMoreText: {
marginLeft: spacing.sm,
marginLeft: 8,
},
// 空状态 - 扁平化风格
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: spacing['3xl'],
paddingTop: 80,
paddingHorizontal: 40,
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#F5F5F7',
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',
},
});
}