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

554 lines
16 KiB
TypeScript
Raw Normal View History

/**
* PostCard
*
*
* @example
* // 新 API 使用方式
* <PostCard
* post={post}
* onAction={(action) => handleAction(post, action)}
* variant="list"
* features="full"
* />
*/
import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common';
import Text from '../../common/Text';
import Avatar from '../../common/Avatar';
import { colors, spacing, borderRadius, fontSizes } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive';
/**
* PostCard
* API
*/
const PostCard: React.FC<PostCardProps> = (normalizedProps) => {
const {
post,
onAction,
variant = 'list',
features,
isPostAuthor = false,
isAuthor = false,
index,
style,
} = normalizedProps;
const [isExpanded, setIsExpanded] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isCompact =
features === 'compact' ||
(typeof features === 'object' && features?.showContent === false);
const emit = (action: PostCardAction) => {
onAction(action);
};
const handleImagePress = (images: ImageGridItem[], imageIndex: number) => {
emit({ type: 'imagePress', payload: { images, imageIndex } });
};
const showGrid = variant === 'grid';
const author = post.author;
const rawContent = post.content ?? '';
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
const handleCardPress = () => emit({ type: 'press' });
const handleUserPress = () => emit({ type: 'userPress' });
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
const handleComment = () => emit({ type: 'comment' });
const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' });
const handleShare = () => emit({ type: 'share' });
const handleDelete = () => {
if (isDeleting) return;
Alert.alert(
'删除帖子',
'确定要删除这篇帖子吗?删除后将无法恢复。',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
emit({ type: 'delete' });
} finally {
setIsDeleting(false);
}
},
},
],
);
};
const images: ImageGridItem[] = Array.isArray(post.images)
? post.images.map((img) => ({
id: img.id,
url: img.url,
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))
: [];
const formatNumber = (num: number): string => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
return String(num);
};
const getResponsiveMaxLength = () => {
if (isWideScreen) return 300;
if (isDesktop) return 250;
if (isTablet) return 200;
return 100;
};
const contentNumberOfLines = useMemo(() => {
if (isWideScreen) return 8;
if (isDesktop) return 6;
if (isTablet) return 5;
return 3;
}, [isWideScreen, isDesktop, isTablet]);
const getTruncatedContent = (value: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength || isExpanded) return value;
return `${value.substring(0, maxLength)}...`;
};
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
const content = useMemo(
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
);
const renderImages = () => {
if (images.length === 0) return null;
if (showGrid) {
const cover = images[0];
const coverUrl = getPreviewImageUrl(cover as any, 'grid');
return (
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
<SmartImage
source={{ uri: coverUrl || cover.url || '' }}
style={styles.gridCover}
resizeMode="cover"
usePreview={true}
/>
</TouchableOpacity>
);
}
return (
<PostImages
images={post.images || []}
displayMode="list"
onImagePress={handleImagePress}
/>
);
};
const renderTopComment = () => {
if (showGrid || !post.top_comment) return null;
return (
<TouchableOpacity style={styles.topCommentBox} activeOpacity={0.85} onPress={handleComment}>
<Text style={styles.topCommentAuthor} numberOfLines={1}>
{post.top_comment.author?.nickname || '匿名用户'}:
</Text>
<Text style={styles.topCommentText} numberOfLines={1}>
{post.top_comment.content}
</Text>
</TouchableOpacity>
);
};
const renderGridCard = () => {
const cover = images[0];
const hasImage = !!cover;
const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : '';
const contentPreview = rawContent
.replace(/[#*_~`\[\]()]/g, '')
.replace(/\n+/g, ' ')
.trim();
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleCardPress}
style={[styles.gridRootCard, style]}
>
{hasImage ? (
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
<SmartImage
source={{ uri: coverUrl || cover.url || '' }}
style={styles.gridCover}
resizeMode="cover"
usePreview={true}
/>
</TouchableOpacity>
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{contentPreview}
</Text>
</View>
)}
{post.is_vote && (
<View style={styles.gridVoteTag}>
<MaterialCommunityIcons name="vote" size={11} color={colors.primary.contrast} />
<Text style={styles.gridVoteTagText}></Text>
</View>
)}
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{post.title}
</Text>
)}
<View style={styles.gridFooter}>
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
</TouchableOpacity>
<View style={styles.gridLikeArea}>
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
</View>
</View>
</TouchableOpacity>
);
};
return (
showGrid ? renderGridCard() : (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleCardPress}
style={[styles.card, style]}
>
<View style={styles.header}>
<TouchableOpacity onPress={handleUserPress} activeOpacity={0.8} style={styles.authorWrap}>
<Avatar source={author?.avatar} size={showGrid ? 22 : 36} name={author?.nickname || '匿名用户'} />
<View style={styles.authorMeta}>
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
{!showGrid && (
<View style={styles.metaRow}>
<Text style={styles.timeText} numberOfLines={1}>
{post.created_at ? new Date(post.created_at).toLocaleDateString() : ''}
</Text>
{post.is_pinned && (
<View style={styles.pinnedTag}>
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
<Text style={styles.pinnedText}></Text>
</View>
)}
{isAuthor && <Text style={styles.badgeText}></Text>}
</View>
)}
</View>
</TouchableOpacity>
{isPostAuthor && (
<TouchableOpacity onPress={handleDelete} hitSlop={8} style={styles.deleteButton} disabled={isDeleting}>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={isDesktop ? 20 : 18}
color={colors.text.hint}
/>
</TouchableOpacity>
)}
</View>
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{post.title}
</Text>
)}
{!isCompact && !!content && (
<>
<Text
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
</TouchableOpacity>
)}
</>
)}
{renderImages()}
{renderTopComment()}
<View style={styles.actions}>
<View style={styles.viewsWrap}>
<MaterialCommunityIcons name="eye-outline" size={16} color={colors.text.hint} />
<Text style={styles.viewsText}>{post.views_count || 0}</Text>
</View>
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
<MaterialCommunityIcons
name={post.is_liked ? 'heart' : 'heart-outline'}
size={18}
color={post.is_liked ? colors.error.main : colors.text.secondary}
/>
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleComment}>
<MaterialCommunityIcons name="comment-outline" size={18} color={colors.text.secondary} />
<Text style={styles.actionText}>{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
<MaterialCommunityIcons name="share-outline" size={18} color={colors.text.secondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
<MaterialCommunityIcons
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
size={18}
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
/>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
)
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm,
},
authorWrap: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
authorMeta: {
marginLeft: spacing.sm,
flex: 1,
minWidth: 0,
},
authorName: {
color: colors.text.primary,
fontWeight: '600',
fontSize: fontSizes.md,
},
timeText: {
color: colors.text.hint,
fontSize: fontSizes.xs,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 2,
},
pinnedTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${colors.warning.light}22`,
borderRadius: borderRadius.sm,
paddingHorizontal: 4,
paddingVertical: 1,
gap: 2,
},
pinnedText: {
color: colors.warning.main,
fontSize: fontSizes.xs,
},
badgeText: {
color: colors.primary.main,
fontSize: fontSizes.xs,
fontWeight: '600',
},
deleteButton: {
padding: spacing.xs,
marginLeft: spacing.sm,
},
title: {
color: colors.text.primary,
fontSize: fontSizes.lg,
fontWeight: '600',
marginBottom: spacing.xs,
},
content: {
color: colors.text.secondary,
fontSize: fontSizes.md,
marginBottom: spacing.xs,
lineHeight: fontSizes.md * 1.45,
},
expandBtn: {
marginBottom: spacing.sm,
},
expandText: {
color: colors.primary.main,
fontSize: fontSizes.sm,
},
topCommentBox: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
marginBottom: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
},
topCommentAuthor: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
fontWeight: '600',
marginRight: 4,
},
topCommentText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
flex: 1,
},
gridCover: {
width: '100%',
aspectRatio: 0.78,
backgroundColor: colors.background.default,
},
actions: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
viewsWrap: {
flexDirection: 'row',
alignItems: 'center',
},
viewsText: {
color: colors.text.hint,
fontSize: fontSizes.sm,
marginLeft: 4,
},
actionButtons: {
flexDirection: 'row',
alignItems: 'center',
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.md,
},
actionText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 4,
},
activeActionText: {
color: colors.error.main,
},
activeActionTextMerged: {
color: colors.error.main,
fontSize: fontSizes.sm,
marginLeft: 4,
},
gridRootCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
},
gridNoImagePreview: {
backgroundColor: colors.background.default,
margin: 6,
borderRadius: borderRadius.md,
minHeight: 120,
padding: 8,
},
gridNoImageText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
lineHeight: 20,
},
gridVoteTag: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: `${colors.primary.main}CC`,
borderRadius: borderRadius.full,
paddingHorizontal: 8,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
gridVoteTagText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
gridTitleMain: {
color: colors.text.primary,
fontSize: fontSizes.md,
lineHeight: 20,
paddingHorizontal: 8,
paddingTop: 8,
minHeight: 44,
},
gridFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
paddingVertical: 10,
},
gridUserArea: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
gridUsername: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 6,
flex: 1,
},
gridLikeArea: {
flexDirection: 'row',
alignItems: 'center',
},
gridLikeCount: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 4,
},
});
export default PostCard;