Files
frontend/src/components/business/CommentItem.tsx
lan 3196972596
All checks were successful
Frontend CI / ota-android (push) Successful in 1m40s
Frontend CI / ota-ios (push) Successful in 1m39s
Frontend CI / build-and-push-web (push) Successful in 3m30s
Frontend CI / build-android-apk (push) Successful in 1h1m8s
refactor: restructure core architecture and responsive system
This commit implements a major architectural refactor to improve modularity, type safety, and maintainability across the codebase.

Key changes include:
- **Responsive System Refactor**: Migrated from a monolithic `useResponsive` hook to a set of specialized, granular hooks (`useBreakpoint`, `useOrientation`, `usePlatform`, etc.) located in `src/presentation/hooks/responsive`. This reduces unnecessary re-renders and improves developer experience.
- **Core Service Restructuring**: Introduced a new directory structure for services, including dedicated folders for `datasources`, `mappers`, and domain-specific types (`message`, `post`).
- **Data Layer Improvements**:
    - Centralized JSON parsing logic in `src/database/core/jsonUtils.ts`.
    - Cleaned up repository implementations by removing redundant local utility functions.
    - Refactored `MessageMapper` to use the new centralized JSON utility.
- **Type System Cleanup**:
    - Decomposed the large `src/types/dto.ts` into a modular `src/types/dto/` directory.
    - Simplified `src/types/index.ts` and introduced backward-compatible aliases for core entities.
- **Utility Consolidation**:
    - Created a centralized `src/utils/formatTime.ts` to replace fragmented date formatting logic across various screens and components.
    - Removed deprecated responsive utility files in favor of the new hook-based system.
- **Service Logic Refinement**: Refactored `ApiClient` and `WebSocketService` to use a centralized `showVerificationModal` service, removing duplicated state management for verification prompts.
2026-05-05 19:07:33 +08:00

686 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 { formatChatTime } from '../../utils/formatTime';
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.sm,
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,
},
subReplyItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.md,
},
subReplyItemLast: {
marginBottom: 0,
},
subReplyBody: {
fontSize: fontSizes.md,
lineHeight: 22,
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.sm,
},
replyToName: {
fontSize: fontSizes.sm,
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 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} selectable style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
{/* 显示回复图片(如果有图片) */}
{renderSubReplyImages(reply)}
{/* 子评论操作按钮 */}
<View style={styles.subReplyActions}>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={14}
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>
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReplyPress?.(reply)}
>
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => handleSubReplyDelete(reply)}
disabled={isDeleting}
>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={14}
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={14} 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}>
{formatChatTime(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} selectable 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;