384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
/**
|
||
* SystemMessageItem 系统消息项组件
|
||
* 根据系统消息类型显示不同图标和内容
|
||
* 参考 QQ 10000 系统消息和微信服务通知样式
|
||
*/
|
||
|
||
import React 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 { colors, spacing, borderRadius } from '../../theme';
|
||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||
import Text from '../common/Text';
|
||
import Avatar from '../common/Avatar';
|
||
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
||
|
||
interface SystemMessageItemProps {
|
||
message: SystemMessageResponse;
|
||
onPress?: () => void;
|
||
onAvatarPress?: () => void; // 头像点击回调
|
||
onRequestAction?: (approve: boolean) => void;
|
||
requestActionLoading?: boolean;
|
||
}
|
||
|
||
// 系统消息类型到图标和颜色的映射
|
||
const getSystemMessageIcon = (
|
||
systemType: SystemMessageType
|
||
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
|
||
switch (systemType) {
|
||
case 'like_post':
|
||
case 'like_comment':
|
||
case 'like_reply':
|
||
case 'favorite_post':
|
||
return {
|
||
icon: 'heart',
|
||
color: colors.error.main,
|
||
bgColor: colors.error.light + '20',
|
||
};
|
||
case 'comment':
|
||
return {
|
||
icon: 'comment',
|
||
color: colors.info.main,
|
||
bgColor: colors.info.light + '20',
|
||
};
|
||
case 'reply':
|
||
return {
|
||
icon: 'reply',
|
||
color: colors.success.main,
|
||
bgColor: colors.success.light + '20',
|
||
};
|
||
case 'follow':
|
||
return {
|
||
icon: 'account-plus',
|
||
color: '#9C27B0', // 紫色
|
||
bgColor: '#9C27B020',
|
||
};
|
||
case 'mention':
|
||
return {
|
||
icon: 'at',
|
||
color: colors.warning.main,
|
||
bgColor: colors.warning.light + '20',
|
||
};
|
||
case 'system':
|
||
return {
|
||
icon: 'cog',
|
||
color: colors.text.secondary,
|
||
bgColor: colors.background.disabled,
|
||
};
|
||
case 'announcement':
|
||
case 'announce':
|
||
return {
|
||
icon: 'bullhorn',
|
||
color: colors.primary.main,
|
||
bgColor: colors.primary.light + '20',
|
||
};
|
||
case 'group_invite':
|
||
return {
|
||
icon: 'account-multiple-plus',
|
||
color: colors.primary.main,
|
||
bgColor: colors.primary.light + '20',
|
||
};
|
||
case 'group_join_apply':
|
||
return {
|
||
icon: 'account-clock',
|
||
color: colors.warning.main,
|
||
bgColor: colors.warning.light + '20',
|
||
};
|
||
case 'group_join_approved':
|
||
return {
|
||
icon: 'check-circle',
|
||
color: colors.success.main,
|
||
bgColor: colors.success.light + '20',
|
||
};
|
||
case 'group_join_rejected':
|
||
return {
|
||
icon: 'close-circle',
|
||
color: colors.error.main,
|
||
bgColor: colors.error.light + '20',
|
||
};
|
||
default:
|
||
return {
|
||
icon: 'bell',
|
||
color: colors.text.secondary,
|
||
bgColor: colors.background.disabled,
|
||
};
|
||
}
|
||
};
|
||
|
||
// 获取系统消息标题
|
||
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;
|
||
|
||
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,
|
||
}) => {
|
||
// 响应式适配
|
||
const { isWideScreen, isDesktop } = useResponsive();
|
||
const containerPadding = useResponsiveValue({ xs: spacing.md, lg: spacing.lg, xl: spacing.xl });
|
||
const avatarSize = useResponsiveValue({ xs: 40, lg: 48, xl: 52 });
|
||
const iconSize = useResponsiveValue({ xs: 20, lg: 22, xl: 24 });
|
||
const contentFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
|
||
const titleFontSize = useResponsiveValue({ xs: 14, lg: 15, xl: 16 });
|
||
const timeFontSize = useResponsiveValue({ xs: 12, lg: 13, xl: 14 });
|
||
// 格式化时间
|
||
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);
|
||
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;
|
||
const requestStatus = extra_data?.request_status;
|
||
const isActionable =
|
||
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
|
||
requestStatus === 'pending' &&
|
||
!!onRequestAction;
|
||
|
||
const styles = React.useMemo(() => StyleSheet.create({
|
||
container: {
|
||
flexDirection: 'row',
|
||
alignItems: 'flex-start',
|
||
padding: containerPadding,
|
||
backgroundColor: colors.background.paper,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.divider,
|
||
},
|
||
iconContainer: {
|
||
marginRight: isWideScreen ? spacing.lg : spacing.md,
|
||
},
|
||
iconWrapper: {
|
||
width: avatarSize,
|
||
height: avatarSize,
|
||
borderRadius: avatarSize / 2,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
},
|
||
content: {
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
},
|
||
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: isWideScreen ? 22 : 18,
|
||
fontSize: contentFontSize,
|
||
},
|
||
extraInfo: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
marginTop: spacing.xs,
|
||
backgroundColor: colors.background.default,
|
||
paddingHorizontal: isWideScreen ? spacing.md : 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: isWideScreen ? spacing.sm : spacing.xs,
|
||
paddingHorizontal: isWideScreen ? spacing.lg : 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,
|
||
},
|
||
}), [containerPadding, isWideScreen, avatarSize, titleFontSize, timeFontSize, contentFontSize]);
|
||
|
||
// 判断是否显示操作者头像
|
||
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;
|