Files
frontend/src/components/business/CommentItem.tsx

700 lines
21 KiB
TypeScript
Raw Normal View History

/**
* CommentItem - /
*
*/
import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { Comment, CommentImage } from '../../types';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { CompactImageGrid, ImageGridItem } from '../common';
import PostContentRenderer from './PostContentRenderer';
interface CommentItemProps {
comment: Comment;
onUserPress: () => void;
onReply: () => void;
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户
onReplyPress?: (comment: Comment) => void; // 点击评论的评论
allReplies?: Comment[]; // 所有回复列表,用于根据 target_id 查找被回复用户
onLoadMoreReplies?: (commentId: string) => void; // 加载更多回复的回调
isCommentAuthor?: boolean; // 当前用户是否为评论作者
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
}
function createCommentItemStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
paddingTop: spacing.md,
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
content: {
flex: 1,
marginLeft: spacing.sm,
minWidth: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
gap: spacing.xs,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
minWidth: 0,
},
metaDot: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginHorizontal: 2,
},
username: {
fontWeight: '600',
fontSize: fontSizes.sm,
color: colors.text.primary,
marginRight: spacing.xs,
},
badge: {
paddingHorizontal: 4,
paddingVertical: 1,
borderRadius: 2,
marginRight: spacing.xs,
},
smallBadge: {
paddingHorizontal: 2,
paddingVertical: 0,
},
authorBadge: {
backgroundColor: colors.primary.main,
},
adminBadge: {
backgroundColor: colors.error.main,
},
badgeText: {
color: colors.text.inverse,
fontSize: fontSizes.xs,
fontWeight: '600',
},
smallBadgeText: {
color: colors.text.inverse,
fontSize: 9,
fontWeight: '600',
},
timeText: {
fontSize: fontSizes.xs,
flexShrink: 0,
},
floorPlain: {
fontSize: fontSizes.xs,
flexShrink: 0,
},
replyReference: {
marginBottom: spacing.xs,
},
commentContent: {
marginBottom: spacing.xs,
},
text: {
lineHeight: 22,
fontSize: fontSizes.md,
color: colors.text.primary,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
flexWrap: 'wrap',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.lg,
paddingVertical: 4,
paddingRight: spacing.xs,
},
actionText: {
marginLeft: 4,
fontSize: fontSizes.xs,
},
subRepliesContainer: {
marginTop: spacing.sm,
marginLeft: 0,
paddingLeft: spacing.sm,
paddingVertical: spacing.xs,
borderLeftWidth: 2,
borderLeftColor: colors.divider,
backgroundColor: colors.background.default,
},
subReplyItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.md,
},
subReplyItemLast: {
marginBottom: 0,
},
subReplyBody: {
fontSize: fontSizes.sm,
lineHeight: 20,
marginTop: 2,
},
subReplyContent: {
flex: 1,
marginLeft: spacing.xs,
},
subReplyHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 2,
},
subReplyAuthor: {
fontWeight: '600',
color: colors.text.primary,
marginRight: spacing.xs,
},
moreRepliesButton: {
marginTop: spacing.xs,
paddingVertical: spacing.xs,
},
replyToText: {
fontSize: fontSizes.xs,
},
replyToName: {
fontSize: fontSizes.xs,
fontWeight: '500',
},
subReplyActions: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
},
subActionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.sm,
paddingVertical: 2,
},
subActionText: {
marginLeft: 2,
fontSize: fontSizes.xs,
},
});
}
const CommentItem: React.FC<CommentItemProps> = ({
comment,
onUserPress,
onReply,
onLike,
floorNumber,
isAuthor = false,
replyToUser,
onReplyPress,
allReplies,
onLoadMoreReplies,
isCommentAuthor = false,
onDelete,
onImagePress,
currentUserId,
onReport,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
};
// 格式化数字
const formatNumber = (num: number): string => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
// 处理图片点击
const handleImagePress = (index: number) => {
if (onImagePress && comment.images && comment.images.length > 0) {
const images: ImageGridItem[] = comment.images.map(img => ({
id: img.url,
url: img.url,
}));
onImagePress(images, index);
}
};
// 渲染评论图片 - 使用 CompactImageGrid
const renderCommentImages = () => {
if (!comment.images || comment.images.length === 0) {
return null;
}
const gridImages: ImageGridItem[] = comment.images.map(img => ({
id: img.url,
url: img.url,
}));
return (
<CompactImageGrid
images={gridImages}
maxDisplayCount={6}
gap={4}
borderRadius={borderRadius.sm}
showMoreOverlay
onImagePress={onImagePress}
/>
);
};
// 处理删除评论
const handleDelete = () => {
if (!onDelete || isDeleting) return;
Alert.alert(
'删除评论',
'确定要删除这条评论吗?删除后将无法恢复。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
await onDelete(comment);
} catch (error) {
console.error('删除评论失败:', error);
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
} finally {
setIsDeleting(false);
}
},
},
],
);
};
// 渲染身份标识
const renderBadges = () => {
const badges = [];
const authorId = comment.author?.id || '';
if (isAuthor) {
badges.push(
<View key="author" style={[styles.badge, styles.authorBadge]}>
<Text variant="caption" style={styles.badgeText}></Text>
</View>
);
}
if (authorId === '1') { // 管理员
badges.push(
<View key="admin" style={[styles.badge, styles.adminBadge]}>
<Text variant="caption" style={styles.badgeText}></Text>
</View>
);
}
return badges;
};
// 渲染楼层号
const renderFloorNumber = () => {
if (!floorNumber) return null;
// 获取楼层显示文本
const getFloorText = (floor: number): string => {
switch (floor) {
case 1:
return '沙发';
case 2:
return '板凳';
case 3:
return '地板';
default:
return `${floor}`;
}
};
return (
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
{getFloorText(floorNumber)}
</Text>
);
};
// 根据 target_id 查找被回复用户的昵称
const getTargetUserNickname = (targetId: string): string => {
// 首先在顶级评论中查找
if (comment.id === targetId) {
return comment.author?.nickname || '用户';
}
// 然后在回复列表中查找
if (allReplies) {
const targetComment = allReplies.find(r => r.id === targetId);
if (targetComment) {
return targetComment.author?.nickname || '用户';
}
}
// 最后在当前评论的 replies 中查找
if (comment.replies) {
const targetComment = comment.replies.find(r => r.id === targetId);
if (targetComment) {
return targetComment.author?.nickname || '用户';
}
}
return '用户';
};
// 渲染子评论图片
const renderSubReplyImages = (reply: Comment) => {
if (!reply.images || reply.images.length === 0) {
return null;
}
const gridImages: ImageGridItem[] = reply.images.map(img => ({
id: img.url,
url: img.url,
}));
return (
<CompactImageGrid
images={gridImages}
maxDisplayCount={3}
gap={4}
borderRadius={borderRadius.sm}
showMoreOverlay
onImagePress={onImagePress}
/>
);
};
// 处理子评论删除
const handleSubReplyDelete = (reply: Comment) => {
if (!onDelete || isDeleting) return;
Alert.alert(
'删除回复',
'确定要删除这条回复吗?删除后将无法恢复。',
[
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
await onDelete(reply);
} catch (error) {
console.error('删除回复失败:', error);
Alert.alert('删除失败', '删除回复时发生错误,请稍后重试');
} finally {
setIsDeleting(false);
}
},
},
],
);
};
// 渲染评论的评论(子评论)- 抖音/b站风格平铺展示
const renderSubReplies = () => {
if (!comment.replies || comment.replies.length === 0) {
return null;
}
const commentAuthorId = comment.author?.id || '';
return (
<View style={styles.subRepliesContainer}>
{comment.replies.map((reply, replyIndex) => {
const replyAuthorId = reply.author?.id || '';
const isLastReply = replyIndex === comment.replies!.length - 1;
// 根据 target_id 获取被回复的用户昵称
const targetId = reply.target_id;
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
// 判断当前用户是否为子评论作者
const isSubReplyAuthor = currentUserId === replyAuthorId;
return (
<TouchableOpacity
key={reply.id}
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
onPress={() => onReplyPress?.(reply)}
activeOpacity={0.7}
>
<Avatar
source={reply.author?.avatar}
size={24}
name={reply.author?.nickname || '用户'}
/>
<View style={styles.subReplyContent}>
<View style={styles.subReplyHeader}>
<Text variant="caption" style={styles.subReplyAuthor}>
{reply.author?.nickname || '用户'}
</Text>
{replyAuthorId === commentAuthorId && (
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
<Text variant="caption" style={styles.smallBadgeText}></Text>
</View>
)}
{/* 显示回复引用aaa 回复 bbb */}
{targetNickname && (
<>
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
{' '}
</Text>
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
{targetNickname}
</Text>
</>
)}
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
{/* 显示回复图片(如果有图片) */}
{renderSubReplyImages(reply)}
{/* 子评论操作按钮 */}
<View style={styles.subReplyActions}>
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReplyPress?.(reply)}
>
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={12}
color={reply.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={reply.is_liked ? colors.error.main : colors.text.hint}
style={styles.subActionText}
>
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => handleSubReplyDelete(reply)}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={12}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
{isDeleting ? '删除中' : '删除'}
</Text>
</TouchableOpacity>
)}
{/* 举报按钮 - 对非子评论作者显示 */}
{!isSubReplyAuthor && onReport && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReport(reply)}
>
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
);
})}
{comment.replies_count > (comment.replies?.length || 0) && (
<TouchableOpacity
style={styles.moreRepliesButton}
onPress={() => onLoadMoreReplies?.(comment.id)}
>
<Text variant="caption" color={colors.primary.main}>
{comment.replies_count - (comment.replies?.length || 0)}
</Text>
</TouchableOpacity>
)}
</View>
);
};
return (
<View style={styles.container}>
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Avatar
source={comment.author?.avatar}
size={40}
name={comment.author?.nickname}
/>
</TouchableOpacity>
{/* 评论内容 */}
<View style={styles.content}>
{/* 用户信息行 - QQ频道风格 */}
<View style={styles.header}>
<View style={styles.userInfo}>
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Text variant="body" style={styles.username} numberOfLines={1}>
{comment.author?.nickname || '用户'}
</Text>
</TouchableOpacity>
{renderBadges()}
<Text style={styles.metaDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(comment.created_at || '')}
</Text>
</View>
{renderFloorNumber()}
</View>
{/* 回复引用 */}
{replyToUser && (
<View style={styles.replyReference}>
<Text variant="caption" color={colors.primary.main}>
@{replyToUser}:
</Text>
</View>
)}
{/* 评论文本 - 非气泡样式 */}
<View style={styles.commentContent}>
{comment.segments && comment.segments.length > 0 ? (
<PostContentRenderer
content={comment.content}
segments={comment.segments}
textStyle={[styles.text, { color: colors.text.primary }]}
/>
) : (
<Text variant="body" color={colors.text.primary} style={styles.text}>
{comment.content}
</Text>
)}
</View>
{/* 评论图片 */}
{renderCommentImages()}
{/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}>
{/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14}
color={comment.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={comment.is_liked ? colors.error.main : colors.text.hint}
style={styles.actionText}
>
{comment.likes_count > 0 ? formatNumber(comment.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 回复 */}
<TouchableOpacity style={styles.actionButton} onPress={onReply}>
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
{/* 举报按钮 - 只对非评论作者显示 */}
{!isCommentAuthor && onReport && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReport(comment)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
)}
{/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && (
<TouchableOpacity
style={styles.actionButton}
onPress={handleDelete}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
{isDeleting ? '删除中' : '删除'}
</Text>
</TouchableOpacity>
)}
</View>
{/* 评论的评论(子评论)- 抖音/b站风格平铺展示不开新层级 */}
{renderSubReplies()}
</View>
</View>
);
};
export default CommentItem;