Files
frontend/src/components/business/CommentItem.tsx
lafay b2c3d5e54e
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 8m30s
Frontend CI / ota-android (push) Successful in 10m43s
Frontend CI / build-android-apk (push) Successful in 1h24m38s
feat(content): add rich content rendering with @mentions and vote segments
- Add PostContentRenderer component for rendering posts/comments with segments
- Add PostMentionInput component for @mention support in post/comment creation
- Add VoteSegmentData and vote segment type for unified post creation API
- Update post and comment services to support segments in API requests
- Fix auth service to clear tokens before login/register
- Improve database initialization with proper close/retry logic
- Add verification check before allowing post creation
- Add metro web shims for react-native-webrtc full package and requireNativeComponent
- Update build config with git hash based version numbers
2026-04-23 22:29:58 +08:00

700 lines
21 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.
/**
* 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;