Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
612
src/components/business/CommentItem.tsx
Normal file
612
src/components/business/CommentItem.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* CommentItem 评论项组件 - QQ频道风格
|
||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
onReply: () => void;
|
||||
onLike: () => 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,用于判断子评论作者
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({
|
||||
comment,
|
||||
onUserPress,
|
||||
onReply,
|
||||
onLike,
|
||||
floorNumber,
|
||||
isAuthor = false,
|
||||
replyToUser,
|
||||
onReplyPress,
|
||||
allReplies,
|
||||
onLoadMoreReplies,
|
||||
isCommentAuthor = false,
|
||||
onDelete,
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
}) => {
|
||||
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 (
|
||||
<View style={styles.floorTag}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据 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) => {
|
||||
const replyAuthorId = reply.author?.id || '';
|
||||
// 根据 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}
|
||||
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="caption" color={colors.text.secondary} numberOfLines={2}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
{/* 显示回复图片(如果有图片) */}
|
||||
{renderSubReplyImages(reply)}
|
||||
{/* 子评论操作按钮 */}
|
||||
<View style={styles.subReplyActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
回复
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 删除按钮 - 子评论作者可见 */}
|
||||
{isSubReplyAuthor && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
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.actionText}>
|
||||
{isDeleting ? '删除中' : '删除'}
|
||||
</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}>
|
||||
<Avatar
|
||||
source={comment.author?.avatar}
|
||||
size={36}
|
||||
name={comment.author?.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<View style={styles.content}>
|
||||
{/* 用户信息行 - QQ频道风格 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.userInfo}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text variant="body" style={styles.username}>
|
||||
{comment.author?.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
<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}>
|
||||
<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}>
|
||||
<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 && (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
},
|
||||
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,
|
||||
},
|
||||
floorTag: {
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
floorText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
export default CommentItem;
|
||||
Reference in New Issue
Block a user