diff --git a/src/components/business/SystemMessageItem.tsx b/src/components/business/SystemMessageItem.tsx index 4d8109b..b10b238 100644 --- a/src/components/business/SystemMessageItem.tsx +++ b/src/components/business/SystemMessageItem.tsx @@ -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 = ({ onAvatarPress, onRequestAction, requestActionLoading = false, + index = 0, }) => { // 格式化时间 const formatTime = (dateString: string): string => { @@ -179,7 +192,6 @@ const SystemMessageItem: React.FC = ({ 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,99 +200,7 @@ const SystemMessageItem: React.FC = ({ (message.system_type === 'group_invite' || message.system_type === 'group_join_apply') && requestStatus === 'pending' && !!onRequestAction; - - // 使用固定的响应式值,兼容移动端和Web端 - const containerPadding = spacing.md; - const avatarSize = 44; - const iconSize = 22; - const contentFontSize = 14; - const titleFontSize = 14; - const timeFontSize = 12; - - const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'flex-start', - padding: containerPadding, - backgroundColor: colors.background.paper, - // 移除底部边框,使用卡片式设计 - marginBottom: 1, - }, - iconContainer: { - marginRight: spacing.md, - }, - iconWrapper: { - width: avatarSize, - height: avatarSize, - borderRadius: avatarSize / 2, - justifyContent: 'center', - alignItems: 'center', - }, - content: { - flex: 1, - justifyContent: 'center', - minWidth: 0, - }, - titleRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: spacing.xs, - }, - title: { - fontWeight: '600', - flex: 1, - marginRight: spacing.sm, - fontSize: titleFontSize, - }, - time: { - fontSize: timeFontSize, - }, - messageContent: { - lineHeight: 20, - fontSize: contentFontSize, - }, - extraInfo: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.xs, - backgroundColor: colors.background.default, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: borderRadius.sm, - alignSelf: 'flex-start', - }, - extraText: { - marginLeft: spacing.xs, - flex: 1, - }, - actionsRow: { - flexDirection: 'row', - marginTop: spacing.sm, - }, - actionBtn: { - paddingVertical: spacing.xs, - paddingHorizontal: spacing.md, - borderRadius: borderRadius.sm, - borderWidth: 1, - marginRight: spacing.sm, - }, - rejectBtn: { - borderColor: colors.error.light, - backgroundColor: colors.error.light + '18', - }, - approveBtn: { - borderColor: colors.success.light, - backgroundColor: colors.success.light + '18', - }, - arrowContainer: { - marginLeft: spacing.sm, - justifyContent: 'center', - alignItems: 'center', - height: 20, - width: 24, - }, - }); + const isUnread = message.is_read !== true; // 判断是否显示操作者头像 const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( @@ -288,31 +208,68 @@ const SystemMessageItem: React.FC = ({ ); const showGroupAvatar = message.system_type === 'group_invite'; + // 获取状态标签 + const getStatusBadge = () => { + if (requestStatus === 'accepted') { + return ( + + + 已同意 + + ); + } + if (requestStatus === 'rejected') { + return ( + + + 已拒绝 + + ); + } + return null; + }; + return ( + {/* 左侧未读指示器 */} + {isUnread && } + {/* 图标/头像区域 */} {showGroupAvatar ? ( - + + + + + + ) : showOperatorAvatar ? ( - + + + + + + ) : ( - + )} @@ -321,36 +278,49 @@ const SystemMessageItem: React.FC = ({ {/* 标题行 */} - - {title} - - + + + {title} + + {getStatusBadge()} + + {formatTime(message.created_at)} {/* 消息内容 */} - + {message.content} - {/* 附加信息 - 优先显示 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 ( - - - - {previewText} - - - ); - })()} + 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 ( + + + + {previewText} + + + ); + })()} + {/* 操作按钮 */} {isActionable && ( = ({ onPress={() => onRequestAction?.(false)} disabled={requestActionLoading} > - 拒绝 + + 拒绝 onRequestAction?.(true)} disabled={requestActionLoading} > - 同意 + + 同意 )} - {/* 右侧箭头(如果有跳转) */} + {/* 右侧箭头 */} {onPress && ( @@ -381,4 +353,151 @@ const SystemMessageItem: React.FC = ({ ); }; +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start', + padding: spacing.md, + backgroundColor: colors.background.paper, + 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: 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', + minWidth: 0, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.xs, + }, + titleLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + marginRight: spacing.sm, + }, + 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: fontSizes.sm, + flexShrink: 0, + }, + messageContent: { + lineHeight: 20, + fontSize: fontSizes.sm, + }, + extraInfo: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.xs, + backgroundColor: colors.primary.light + '12', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + 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.md, + borderWidth: 1, + gap: spacing.xs, + }, + rejectBtn: { + borderColor: '#FFCDD2', + backgroundColor: '#FFEBEE', + }, + approveBtn: { + borderColor: '#C8E6C9', + backgroundColor: '#E8F5E9', + }, + actionBtnText: { + fontWeight: '500', + }, + arrowContainer: { + marginLeft: spacing.sm, + justifyContent: 'center', + alignItems: 'center', + height: 20, + width: 20, + }, +}); + export default SystemMessageItem; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index f622470..17d9297 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -337,6 +337,11 @@ export const CreatePostScreen: React.FC = (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 = (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 = (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 = (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 = (props) => { ]} value={title} onChangeText={setTitle} - placeholder="添加标题(可选)" + placeholder="请输入标题(必填)" placeholderTextColor={colors.text.hint} maxLength={MAX_TITLE_LENGTH} /> diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index a3a4d29..84def36 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -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); diff --git a/src/screens/message/NotificationsScreen.tsx b/src/screens/message/NotificationsScreen.tsx index 028c566..d8ef14b 100644 --- a/src/screens/message/NotificationsScreen.tsx +++ b/src/screens/message/NotificationsScreen.tsx @@ -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,