Files
frontend/src/components/business/SystemMessageItem.tsx
lafay 4ee3079b9f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
feat(Theme): enhance theming and UI consistency across components
- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability.
- Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage.
- Introduced SystemChrome component to manage system UI background color based on theme.
- Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure.
- Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system.
- Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
2026-03-25 05:16:54 +08:00

509 lines
16 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.
/**
* SystemMessageItem 系统消息项组件 - 美化版
* 根据系统消息类型显示不同图标和内容
* 采用卡片式设计符合胡萝卜BBS整体风格
*/
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
interface SystemMessageItemProps {
message: SystemMessageResponse;
onPress?: () => void;
onAvatarPress?: () => void;
onRequestAction?: (approve: boolean) => void;
requestActionLoading?: boolean;
index?: number; // 用于交错动画
}
const getSystemMessageIcon = (
systemType: SystemMessageType,
colors: AppColors
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
switch (systemType) {
case 'like_post':
case 'like_comment':
case 'like_reply':
case 'favorite_post':
return {
icon: 'heart',
color: '#FF4757',
bgColor: '#FFE4E6',
gradient: ['#FF4757', '#FF6B7A'],
};
case 'comment':
return {
icon: 'comment-text',
color: '#2196F3',
bgColor: '#E3F2FD',
gradient: ['#2196F3', '#64B5F6'],
};
case 'reply':
return {
icon: 'reply',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'follow':
return {
icon: 'account-plus',
color: '#9C27B0',
bgColor: '#F3E5F5',
gradient: ['#9C27B0', '#BA68C8'],
};
case 'mention':
return {
icon: 'at',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'system':
return {
icon: 'cog',
color: '#607D8B',
bgColor: '#ECEFF1',
gradient: ['#607D8B', '#90A4AE'],
};
case 'announcement':
case 'announce':
return {
icon: 'bullhorn',
color: colors.primary.main,
bgColor: colors.primary.light + '30',
gradient: [colors.primary.main, colors.primary.light],
};
case 'group_invite':
return {
icon: 'account-multiple-plus',
color: '#00BCD4',
bgColor: '#E0F7FA',
gradient: ['#00BCD4', '#4DD0E1'],
};
case 'group_join_apply':
return {
icon: 'account-clock',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'group_join_approved':
return {
icon: 'check-circle',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'group_join_rejected':
return {
icon: 'close-circle',
color: '#F44336',
bgColor: '#FFEBEE',
gradient: ['#F44336', '#E57373'],
};
default:
return {
icon: 'bell',
color: '#757575',
bgColor: '#F5F5F5',
gradient: ['#757575', '#9E9E9E'],
};
}
};
function createSystemMessageStyles(colors: AppColors) {
return 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: `${colors.error.main}55`,
backgroundColor: `${colors.error.main}18`,
},
approveBtn: {
borderColor: `${colors.success.main}55`,
backgroundColor: `${colors.success.main}18`,
},
actionBtnText: {
fontWeight: '500',
},
arrowContainer: {
marginLeft: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
height: 20,
width: 20,
},
});
}
// 获取系统消息标题
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
const { system_type, extra_data } = message;
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const groupName = extra_data?.group_name || extra_data?.target_title;
switch (system_type) {
case 'like_post':
return operatorName ? `${operatorName} 赞了你的帖子` : '有人赞了你的帖子';
case 'like_comment':
return operatorName ? `${operatorName} 赞了你的评论` : '有人赞了你的评论';
case 'like_reply':
return operatorName ? `${operatorName} 赞了你的回复` : '有人赞了你的回复';
case 'favorite_post':
return operatorName ? `${operatorName} 收藏了你的帖子` : '有人收藏了你的帖子';
case 'comment':
return operatorName ? `${operatorName} 评论了你的帖子` : '有人评论了你的帖子';
case 'reply':
return operatorName ? `${operatorName} 回复了你` : '有人回复了你';
case 'follow':
return operatorName ? `${operatorName} 关注了你` : '有人关注了你';
case 'mention':
return operatorName ? `${operatorName} @提到了你` : '有人@提到了你';
case 'system':
return '系统通知';
case 'announcement':
return '公告';
case 'group_invite':
return operatorName
? `${operatorName} 邀请加入群聊 ${groupName || ''}`.trim()
: `收到群邀请${groupName ? `${groupName}` : ''}`;
case 'group_join_apply':
if (extra_data?.request_status === 'accepted') {
return operatorName ? `${operatorName} 已同意` : '该请求已同意';
}
if (extra_data?.request_status === 'rejected') {
return operatorName ? `${operatorName} 已拒绝` : '该请求已拒绝';
}
return operatorName
? `${operatorName} 申请加入群聊 ${groupName || ''}`.trim()
: `收到加群申请${groupName ? `${groupName}` : ''}`;
case 'group_join_approved':
return '加群申请已通过';
case 'group_join_rejected':
return '加群申请被拒绝';
default:
return '通知';
}
};
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message,
onPress,
onAvatarPress,
onRequestAction,
requestActionLoading = false,
index = 0,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
const title = getSystemMessageTitle(message);
const { extra_data } = message;
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;
const requestStatus = extra_data?.request_status;
const isActionable =
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
requestStatus === 'pending' &&
!!onRequestAction;
const isUnread = message.is_read !== true;
// 判断是否显示操作者头像
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: `${colors.success.main}22` }]}>
<MaterialCommunityIcons name="check" size={10} color={colors.success.main} />
<Text variant="caption" color={colors.success.main} style={styles.statusText}></Text>
</View>
);
}
if (requestStatus === 'rejected') {
return (
<View style={[styles.statusBadge, { backgroundColor: `${colors.error.main}22` }]}>
<MaterialCommunityIcons name="close" size={10} color={colors.error.main} />
<Text variant="caption" color={colors.error.main} 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={colors.error.main} />
<Text variant="caption" color={colors.error.main} style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading}
>
<MaterialCommunityIcons name="check" size={14} color={colors.success.main} />
<Text variant="caption" color={colors.success.main} style={styles.actionBtnText}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 右侧箭头 */}
{onPress && (
<View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
</View>
)}
</TouchableOpacity>
);
};
export default SystemMessageItem;