feat(ui): redesign system message item with card-style layout and add title validation
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m39s
Frontend CI / ota-android (push) Successful in 12m4s
Frontend CI / build-android-apk (push) Successful in 44m0s

- Refactor SystemMessageItem with modern card design, gradient colors, and improved visual hierarchy
- Add unread indicator, status badges, and enhanced avatar badges for message types
- Update CreatePostScreen to require title input and improve placeholder text
- Add Android back button handling for notification screens in embedded mode
- Replace manual shadow styles with theme shadow utilities across message screens
This commit is contained in:
lafay
2026-03-21 12:08:45 +08:00
parent a8c78f0c3f
commit 37ddfa8ed6
4 changed files with 326 additions and 173 deletions

View File

@@ -1,15 +1,15 @@
/**
* SystemMessageItem 系统消息项组件
* SystemMessageItem 系统消息项组件 - 美化版
* 根据系统消息类型显示不同图标和内容
* 参考 QQ 10000 系统消息和微信服务通知样式
* 采用卡片式设计符合胡萝卜BBS整体风格
*/
import React from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { View, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { colors, spacing, borderRadius } from '../../theme';
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
@@ -17,15 +17,16 @@ import Avatar from '../common/Avatar';
interface SystemMessageItemProps {
message: SystemMessageResponse;
onPress?: () => void;
onAvatarPress?: () => void; // 头像点击回调
onAvatarPress?: () => void;
onRequestAction?: (approve: boolean) => void;
requestActionLoading?: boolean;
index?: number; // 用于交错动画
}
// 系统消息类型到图标和颜色的映射
const getSystemMessageIcon = (
systemType: SystemMessageType
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
switch (systemType) {
case 'like_post':
case 'like_comment':
@@ -33,75 +34,87 @@ const getSystemMessageIcon = (
case 'favorite_post':
return {
icon: 'heart',
color: colors.error.main,
bgColor: colors.error.light + '20',
color: '#FF4757',
bgColor: '#FFE4E6',
gradient: ['#FF4757', '#FF6B7A'],
};
case 'comment':
return {
icon: 'comment',
color: colors.info.main,
bgColor: colors.info.light + '20',
icon: 'comment-text',
color: '#2196F3',
bgColor: '#E3F2FD',
gradient: ['#2196F3', '#64B5F6'],
};
case 'reply':
return {
icon: 'reply',
color: colors.success.main,
bgColor: colors.success.light + '20',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'follow':
return {
icon: 'account-plus',
color: '#9C27B0', // 紫色
bgColor: '#9C27B020',
color: '#9C27B0',
bgColor: '#F3E5F5',
gradient: ['#9C27B0', '#BA68C8'],
};
case 'mention':
return {
icon: 'at',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'system':
return {
icon: 'cog',
color: colors.text.secondary,
bgColor: colors.background.disabled,
color: '#607D8B',
bgColor: '#ECEFF1',
gradient: ['#607D8B', '#90A4AE'],
};
case 'announcement':
case 'announce':
return {
icon: 'bullhorn',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
bgColor: colors.primary.light + '30',
gradient: [colors.primary.main, colors.primary.light],
};
case 'group_invite':
return {
icon: 'account-multiple-plus',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
color: '#00BCD4',
bgColor: '#E0F7FA',
gradient: ['#00BCD4', '#4DD0E1'],
};
case 'group_join_apply':
return {
icon: 'account-clock',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'group_join_approved':
return {
icon: 'check-circle',
color: colors.success.main,
bgColor: colors.success.light + '20',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'group_join_rejected':
return {
icon: 'close-circle',
color: colors.error.main,
bgColor: colors.error.light + '20',
color: '#F44336',
bgColor: '#FFEBEE',
gradient: ['#F44336', '#E57373'],
};
default:
return {
icon: 'bell',
color: colors.text.secondary,
bgColor: colors.background.disabled,
color: '#757575',
bgColor: '#F5F5F5',
gradient: ['#757575', '#9E9E9E'],
};
}
};
@@ -109,7 +122,6 @@ const getSystemMessageIcon = (
// 获取系统消息标题
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
const { system_type, extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const groupName = extra_data?.group_name || extra_data?.target_title;
@@ -163,6 +175,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onAvatarPress,
onRequestAction,
requestActionLoading = false,
index = 0,
}) => {
// 格式化时间
const formatTime = (dateString: string): string => {
@@ -179,7 +192,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
const title = getSystemMessageTitle(message);
const { extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar;
const groupAvatar = extra_data?.group_avatar;
@@ -188,34 +200,210 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
requestStatus === 'pending' &&
!!onRequestAction;
const isUnread = message.is_read !== true;
// 使用固定的响应式值兼容移动端和Web端
const containerPadding = spacing.md;
const avatarSize = 44;
const iconSize = 22;
const contentFontSize = 14;
const titleFontSize = 14;
const timeFontSize = 12;
// 判断是否显示操作者头像
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
message.system_type
);
const showGroupAvatar = message.system_type === 'group_invite';
// 获取状态标签
const getStatusBadge = () => {
if (requestStatus === 'accepted') {
return (
<View style={[styles.statusBadge, { backgroundColor: '#E8F5E9' }]}>
<MaterialCommunityIcons name="check" size={10} color="#4CAF50" />
<Text variant="caption" color="#4CAF50" style={styles.statusText}></Text>
</View>
);
}
if (requestStatus === 'rejected') {
return (
<View style={[styles.statusBadge, { backgroundColor: '#FFEBEE' }]}>
<MaterialCommunityIcons name="close" size={10} color="#F44336" />
<Text variant="caption" color="#F44336" style={styles.statusText}></Text>
</View>
);
}
return null;
};
return (
<TouchableOpacity
style={[
styles.container,
isUnread && styles.unreadContainer,
]}
onPress={onPress}
activeOpacity={onPress ? 0.85 : 1}
disabled={!onPress}
>
{/* 左侧未读指示器 */}
{isUnread && <View style={styles.unreadIndicator} />}
{/* 图标/头像区域 */}
<View style={styles.iconContainer}>
{showGroupAvatar ? (
<View style={styles.avatarWrapper}>
<Avatar
source={groupAvatar || ''}
size={48}
name={extra_data?.group_name || '群聊'}
/>
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : showOperatorAvatar ? (
<View style={styles.avatarWrapper}>
<Avatar
source={operatorAvatar || ''}
size={48}
name={operatorName}
onPress={onAvatarPress}
/>
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : (
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={24} color={color} />
</View>
)}
</View>
{/* 内容区域 */}
<View style={styles.content}>
{/* 标题行 */}
<View style={styles.titleRow}>
<View style={styles.titleLeft}>
<Text
variant="body"
style={[styles.title, ...(isUnread ? [styles.unreadTitle] : [])]}
numberOfLines={1}
>
{title}
</Text>
{getStatusBadge()}
</View>
<Text variant="caption" color={colors.text.hint} style={styles.time}>
{formatTime(message.created_at)}
</Text>
</View>
{/* 消息内容 */}
<Text
variant="caption"
color={colors.text.secondary}
numberOfLines={2}
style={styles.messageContent}
>
{message.content}
</Text>
{/* 附加信息 */}
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) &&
!['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) &&
(() => {
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
const iconName = isCommentType ? 'comment-text-outline' : 'file-document-outline';
return (
<View style={styles.extraInfo}>
<MaterialCommunityIcons name={iconName} size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} numberOfLines={1} style={styles.extraText}>
{previewText}
</Text>
</View>
);
})()}
{/* 操作按钮 */}
{isActionable && (
<View style={styles.actionsRow}>
<TouchableOpacity
style={[styles.actionBtn, styles.rejectBtn]}
onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading}
>
<MaterialCommunityIcons name="close" size={14} color="#F44336" />
<Text variant="caption" color="#F44336" style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading}
>
<MaterialCommunityIcons name="check" size={14} color="#4CAF50" />
<Text variant="caption" color="#4CAF50" style={styles.actionBtnText}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 右侧箭头 */}
{onPress && (
<View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</View>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: containerPadding,
padding: spacing.md,
backgroundColor: colors.background.paper,
// 移除底部边框,使用卡片式设计
marginBottom: 1,
borderRadius: borderRadius.lg,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
...shadows.sm,
},
unreadContainer: {
backgroundColor: colors.primary.light + '08',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
},
unreadIndicator: {
position: 'absolute',
left: 6,
top: '50%',
marginTop: -4,
width: 8,
height: 8,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
},
iconContainer: {
marginRight: spacing.md,
},
avatarWrapper: {
position: 'relative',
},
iconWrapper: {
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
width: 48,
height: 48,
borderRadius: borderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
typeIconBadge: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: borderRadius.full,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
content: {
flex: 1,
justifyContent: 'center',
@@ -227,158 +415,89 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
alignItems: 'center',
marginBottom: spacing.xs,
},
title: {
fontWeight: '600',
titleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: spacing.sm,
fontSize: titleFontSize,
},
title: {
fontWeight: '500',
fontSize: fontSizes.md,
color: colors.text.primary,
},
unreadTitle: {
fontWeight: '600',
color: colors.text.primary,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.xs,
},
statusText: {
fontSize: 10,
fontWeight: '500',
marginLeft: 2,
},
time: {
fontSize: timeFontSize,
fontSize: fontSizes.sm,
flexShrink: 0,
},
messageContent: {
lineHeight: 20,
fontSize: contentFontSize,
fontSize: fontSizes.sm,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
backgroundColor: colors.background.default,
backgroundColor: colors.primary.light + '12',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.sm,
borderRadius: borderRadius.md,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: spacing.xs,
flex: 1,
fontWeight: '500',
},
actionsRow: {
flexDirection: 'row',
marginTop: spacing.sm,
gap: spacing.sm,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.sm,
borderRadius: borderRadius.md,
borderWidth: 1,
marginRight: spacing.sm,
gap: spacing.xs,
},
rejectBtn: {
borderColor: colors.error.light,
backgroundColor: colors.error.light + '18',
borderColor: '#FFCDD2',
backgroundColor: '#FFEBEE',
},
approveBtn: {
borderColor: colors.success.light,
backgroundColor: colors.success.light + '18',
borderColor: '#C8E6C9',
backgroundColor: '#E8F5E9',
},
actionBtnText: {
fontWeight: '500',
},
arrowContainer: {
marginLeft: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
height: 20,
width: 24,
width: 20,
},
});
// 判断是否显示操作者头像
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
message.system_type
);
const showGroupAvatar = message.system_type === 'group_invite';
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
disabled={!onPress}
>
{/* 图标/头像区域 */}
<View style={styles.iconContainer}>
{showGroupAvatar ? (
<Avatar
source={groupAvatar || ''}
size={avatarSize}
name={extra_data?.group_name || '群聊'}
/>
) : showOperatorAvatar ? (
<Avatar
source={operatorAvatar || ''}
size={avatarSize}
name={operatorName}
onPress={onAvatarPress}
/>
) : (
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={iconSize} color={color} />
</View>
)}
</View>
{/* 内容区域 */}
<View style={styles.content}>
{/* 标题行 */}
<View style={styles.titleRow}>
<Text variant="body" style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text variant="caption" color={colors.text.secondary} style={styles.time}>
{formatTime(message.created_at)}
</Text>
</View>
{/* 消息内容 */}
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.messageContent}>
{message.content}
</Text>
{/* 附加信息 - 优先显示 target_title图标根据 target_type 区分 */}
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) &&
!['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) &&
(() => {
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
const iconName = isCommentType ? 'comment-outline' : 'file-document-outline';
return (
<View style={styles.extraInfo}>
<MaterialCommunityIcons name={iconName} size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.extraText}>
{previewText}
</Text>
</View>
);
})()}
{isActionable && (
<View style={styles.actionsRow}>
<TouchableOpacity
style={[styles.actionBtn, styles.rejectBtn]}
onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.error.main}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.success.main}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 右侧箭头(如果有跳转) */}
{onPress && (
<View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</View>
)}
</TouchableOpacity>
);
};
export default SystemMessageItem;

View File

@@ -337,6 +337,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 防止重复点击
if (posting) return;
if (!title.trim()) {
Alert.alert('错误', '请输入帖子标题');
return;
}
if (!content.trim()) {
Alert.alert('错误', '请输入帖子内容');
return;
@@ -365,7 +370,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 创建投票帖子
await voteService.createVotePost({
title: title.trim() || '无标题',
title: title.trim(),
content: content.trim(),
images: imageUrls,
vote_options: validOptions,
@@ -383,7 +388,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
} else {
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
title: title.trim() || '无标题',
title: title.trim(),
content: content.trim(),
images: imageUrls,
});
@@ -403,7 +408,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
} else {
// 创建普通帖子
await postService.createPost({
title: title.trim() || '无标题',
title: title.trim(),
content: content.trim(),
images: imageUrls,
});
@@ -562,7 +567,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
]}
value={title}
onChangeText={setTitle}
placeholder="添加标题(可选"
placeholder="请输入标题(必填"
placeholderTextColor={colors.text.hint}
maxLength={MAX_TITLE_LENGTH}
/>

View File

@@ -22,6 +22,7 @@ import {
Animated,
TextInput,
Keyboard,
BackHandler,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useIsFocused } from '@react-navigation/native';
@@ -283,6 +284,19 @@ export const MessageListScreen: React.FC = () => {
}
}, [isFocused]);
// 处理 Android 物理返回键 - 当显示通知页面时,返回键关闭通知页面
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (showNotifications) {
setShowNotifications(false);
return true; // 阻止默认行为
}
return false;
});
return () => backHandler.remove();
}, [showNotifications]);
// 下拉刷新
const onRefresh = useCallback(async () => {
setRefreshing(true);

View File

@@ -14,13 +14,14 @@ import {
RefreshControl,
ActivityIndicator,
Dimensions,
BackHandler,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, borderRadius } from '../../theme';
import { colors, spacing, borderRadius, shadows } from '../../theme';
import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService';
@@ -182,6 +183,21 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ 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
@@ -558,12 +574,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
backgroundColor: colors.background.paper,
// 移除底部边框,使用更柔和的分隔方式
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 2,
...shadows.sm,
},
headerWide: {
paddingHorizontal: spacing.xl,
@@ -613,7 +624,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
// 移除底部边框,让界面更简洁
},
filterContainerWideWeb: {
paddingHorizontal: spacing.xl,
@@ -629,6 +639,8 @@ const styles = StyleSheet.create({
marginRight: spacing.xs,
borderRadius: borderRadius.lg,
backgroundColor: colors.background.default,
borderWidth: 1,
borderColor: 'transparent',
},
filterTagWide: {
paddingHorizontal: spacing.lg,
@@ -637,6 +649,7 @@ const styles = StyleSheet.create({
},
filterTagActive: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
filterTagTextActive: {
fontWeight: '600',
@@ -655,7 +668,8 @@ const styles = StyleSheet.create({
},
listContent: {
flexGrow: 1,
paddingTop: spacing.sm,
paddingTop: spacing.md,
paddingBottom: spacing.lg,
},
listContentWide: {
paddingHorizontal: spacing.xl,
@@ -679,6 +693,7 @@ const styles = StyleSheet.create({
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: spacing['3xl'],
},
emptyContainerWeb: {
minHeight: 500,